From aaa94eb9d312ced864630c50d3feb4d416243ec4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Tue, 31 Mar 2026 05:58:24 +0900 Subject: [PATCH 001/209] fix(reviewer-bot): harden GitHub API failure handling --- .../reviewer-bot-tests/test_reviewer_bot.py | 776 +++++++++++++++++- scripts/reviewer_bot.py | 20 +- scripts/reviewer_bot_lib/automation.py | 41 +- scripts/reviewer_bot_lib/commands.py | 67 +- scripts/reviewer_bot_lib/config.py | 5 +- scripts/reviewer_bot_lib/context.py | 9 + scripts/reviewer_bot_lib/github_api.py | 340 ++++++-- scripts/reviewer_bot_lib/lease_lock.py | 22 +- scripts/reviewer_bot_lib/lifecycle.py | 75 +- scripts/reviewer_bot_lib/maintenance.py | 85 +- scripts/reviewer_bot_lib/overdue.py | 16 +- scripts/reviewer_bot_lib/reconcile.py | 120 ++- scripts/reviewer_bot_lib/reviews.py | 215 +++-- scripts/reviewer_bot_lib/state_store.py | 3 + scripts/reviewer_bot_lib/sweeper.py | 74 +- 15 files changed, 1649 insertions(+), 219 deletions(-) diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index 28accb930..8b80ef693 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -12,6 +12,20 @@ from scripts.reviewer_bot_lib import comment_routing, sweeper +class FakeGitHubResponse: + def __init__(self, status_code, payload=None, text="", headers=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + self.content = b"" if payload is None and not text else b"x" + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + def make_state(epoch: str = "freshness_v15"): return { "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, @@ -136,6 +150,133 @@ def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY +def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["retry_policy"] = kwargs.get("retry_policy") + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, + headers={"etag": '"abc"'}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + snapshot = reviewer_bot.get_state_issue_snapshot() + + assert snapshot is not None + assert snapshot.etag == '"abc"' + assert observed["retry_policy"] == "idempotent_read" + + +def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": data["body"]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') + + assert observed["extra_headers"] == {"If-Match": '"etag-1"'} + + +def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): + observed = [] + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed.append((endpoint, kwargs.get("retry_policy"))) + if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: + return reviewer_bot.GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/reviewer-bot-state-lock": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/main": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + if endpoint == "git/refs": + return reviewer_bot.GitHubApiResult( + status_code=201, + payload={"ref": "refs/heads/reviewer-bot-state-lock"}, + headers={}, + text="created", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" + assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") + assert observed[1] == ("git/ref/heads/main", "idempotent_read") + + +def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="expected-token", + lock_owner_run_id="run-1", + lock_owner_workflow="wf", + lock_owner_job="job", + state_issue_url="https://example.com/state/1", + ) + monkeypatch.setattr( + reviewer_bot, + "get_lock_ref_snapshot", + lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), + ) + + assert reviewer_bot.renew_state_issue_lease_lock(context) is False + + def test_reviewer_board_preflight_validates_manifest(monkeypatch): monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") @@ -148,6 +289,107 @@ def test_reviewer_board_preflight_validates_manifest(monkeypatch): assert preflight.project_id == "PVT_kwDOB" +def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + assert result.failure_kind is None + + +def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + + +def test_github_api_request_classifies_not_found_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(404, {"message": "missing"}, "missing") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + + assert result.ok is False + assert result.failure_kind == "not_found" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + + +def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + + assert result.ok is False + assert result.failure_kind == "forbidden" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + + +def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"data": {"viewer": {"login": "bot"}}} + assert result.retry_attempts == 1 + + @pytest.mark.parametrize( ("env", "expected"), [ @@ -479,7 +721,14 @@ def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypat review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" review["transition_warning_sent"] = "2026-03-10T00:00:00Z" monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) posted = [] @@ -510,7 +759,14 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) monkeypatch.setattr( @@ -533,6 +789,122 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" +def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation(monkeypatch): + review_data = { + "active_head_sha": "head-1", + "contributor_revision": {"accepted": None}, + } + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result == reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ) + assert review_data["active_head_sha"] == "head-1" + + +def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): + review_data = { + "active_head_sha": "head-1", + "contributor_revision": {"accepted": None}, + "current_cycle_completion": {"completed": True}, + "current_cycle_write_approval": {"has_write_approval": True}, + "review_completed_at": "2026-03-10T00:00:00Z", + "review_completed_by": "alice", + "review_completion_source": "live_review_rebuild", + } + accepted = [] + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-2"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_channel_event", + lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True, + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "changed" + assert result.changed is True + assert review_data["active_head_sha"] == "head-2" + assert accepted[0][0] == "contributor_revision" + assert accepted[0][1]["semantic_key"] == "pull_request_head_observed:42:head-2" + assert review_data["current_cycle_completion"] == {} + assert review_data["current_cycle_write_approval"] == {} + assert review_data["review_completed_at"] is None + + +def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): + state = make_state() + review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) + review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) + assert review_42 is not None + assert review_43 is not None + review_42["current_reviewer"] = "alice" + review_43["current_reviewer"] = "bob" + overdue_called = [] + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: overdue_called.append(True) or []) + monkeypatch.setattr(reviewer_bot.reviews_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) + + def fake_head_repair(bot, issue_number, review_data): + if issue_number == 42: + return reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ) + review_data["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "stale", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") + + monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert overdue_called == [True] + assert review_42["repair_needed"]["kind"] == "live_read_failure" + assert review_42["repair_needed"]["phase"] == "head_observation_repair" + assert review_42["repair_needed"]["failure_kind"] == "server_error" + assert review_43["repair_needed"] is None + + def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -567,6 +939,97 @@ def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkey assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] +def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) + + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + + +def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) + + assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False + assert review["transition_warning_sent"] is None + + +def test_remove_label_reports_failure(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=500, + payload={"message": "boom"}, + headers={}, + text="boom", + ok=False, + failure_kind="server_error", + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False + + +def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" + assert reviewer_bot.check_user_permission("alice", "triage") is None + + +def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_issue_assignees(42) is None + + +def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_LABELS", json.dumps(["coding guideline"])) + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + with pytest.raises(RuntimeError, match="Unable to determine assignees"): + reviewer_bot.handle_issue_or_pr_opened(state) + + def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) @@ -1123,26 +1586,41 @@ def test_project_status_labels_emits_awaiting_write_approval_only_after_completi lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) - def fake_api(method, endpoint, data=None): + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}} - return None + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "bob"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) - monkeypatch.setattr(reviewer_bot, "github_api", fake_api) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "bob"}, - } - ], - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": False) + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} assert metadata["state"] == "awaiting_write_approval" @@ -1151,6 +1629,121 @@ def fake_api(method, endpoint, data=None): assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} +def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "reviews_unavailable" + + +def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "permission_unavailable" + + +def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="server_error"): + reviewer_bot.list_open_items_with_status_labels() + + def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -1875,6 +2468,74 @@ def fake_github_api(method, endpoint, data=None): assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] +def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment-unavailable.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 603, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:205", + "pr_number": 42, + "comment_id": 205, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "issues/comments/205": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" + + def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -2068,6 +2729,51 @@ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_ assert pending["result"] == "live_revalidation_failed" +def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): + called = {"post": 0} + monkeypatch.setattr(reviewer_bot, "find_open_pr_for_branch_status", lambda branch: ("unavailable", None)) + monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None) + + with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): + reviewer_bot.create_pull_request("feature-branch", "main", 42) + + assert called["post"] == 0 + + +def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") + monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): + reviewer_bot.resolve_workflow_run_pr_number() + + +def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "F\u00e9lix Fischer"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + def test_assign_command_posts_pr_guidance_on_success(monkeypatch): state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] @@ -2322,6 +3028,36 @@ def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): assert result["reason"] == "ambiguous_exact_artifact_matches" +def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"artifacts": [{"id": 1, "name": "artifact"}]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ), + ) + + assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] + + +def test_evaluate_gap_state_treats_artifact_download_unavailable_as_unknown(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "download_unavailable"}}, + ) + assert reason == "observer_state_unknown" + assert diagnostic == "artifact_download_unavailable" + + def test_evaluate_gap_state_only_emits_missing_after_negative_inference_contract(): reason, diagnostic = sweeper.evaluate_deferred_gap_state( { diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 61a54c3b5..1043cfdfb 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -213,6 +213,8 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, *, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: return github_api_module.github_api_request( @@ -221,6 +223,8 @@ def github_api_request( endpoint, data, extra_headers, + retry_policy=retry_policy, + timeout_seconds=timeout_seconds, suppress_error_log=suppress_error_log, ) @@ -234,6 +238,8 @@ def github_graphql_request( variables: dict | None = None, *, token: str | None = None, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: return github_api_module.github_graphql_request( @@ -241,6 +247,8 @@ def github_graphql_request( query, variables, token=token, + retry_policy=retry_policy, + timeout_seconds=timeout_seconds, suppress_error_log=suppress_error_log, ) @@ -346,7 +354,7 @@ def get_assignment_failure_comment(reviewer: str, attempt: AssignmentAttempt) -> return github_api_module.get_assignment_failure_comment(_runtime_bot(), reviewer, attempt) -def get_issue_assignees(issue_number: int) -> list[str]: +def get_issue_assignees(issue_number: int) -> list[str] | None: return github_api_module.get_issue_assignees(_runtime_bot(), issue_number) @@ -366,7 +374,11 @@ def unassign_reviewer(issue_number: int, username: str) -> bool: return github_api_module.unassign_reviewer(_runtime_bot(), issue_number, username) -def check_user_permission(username: str, required_permission: str = "triage") -> bool: +def get_user_permission_status(username: str, required_permission: str = "triage") -> str: + return github_api_module.get_user_permission_status(_runtime_bot(), username, required_permission) + + +def check_user_permission(username: str, required_permission: str = "triage") -> bool | None: return github_api_module.check_user_permission(_runtime_bot(), username, required_permission) @@ -629,6 +641,10 @@ def find_open_pr_for_branch(branch: str) -> dict | None: return automation_module.find_open_pr_for_branch(_runtime_bot(), branch) +def find_open_pr_for_branch_status(branch: str) -> tuple[str, dict | None]: + return automation_module.find_open_pr_for_branch_status(_runtime_bot(), branch) + + def resolve_workflow_run_pr_number() -> int: return commands_module.resolve_workflow_run_pr_number(_runtime_bot()) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 95ba4872a..72e8f40eb 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -46,23 +46,41 @@ def get_default_branch(bot) -> str: return "main" -def find_open_pr_for_branch(bot, branch: str) -> dict | None: +def find_open_pr_for_branch_status(bot, branch: str) -> tuple[str, dict | None]: owner = os.environ.get("REPO_OWNER", "").strip() branch = branch.strip() if not owner or not branch: - return None - response = bot.github_api("GET", f"pulls?state=open&head={owner}:{branch}") - if isinstance(response, list) and response: - first = response[0] + return "not_found", None + response = bot.github_api_request( + "GET", + f"pulls?state=open&head={owner}:{branch}", + retry_policy="idempotent_read", + ) + if not response.ok: + return "unavailable", None + payload = response.payload + if not isinstance(payload, list): + return "unavailable", None + if payload: + first = payload[0] if isinstance(first, dict): - return first - return None + return "found", first + return "not_found", None + + +def find_open_pr_for_branch(bot, branch: str) -> dict | None: + status, pr = find_open_pr_for_branch_status(bot, branch) + if status != "found": + return None + return pr def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None: - existing = bot.find_open_pr_for_branch(branch) - if existing: + lookup_status, existing = bot.find_open_pr_for_branch_status(branch) + if lookup_status == "found": return existing + if lookup_status == "unavailable": + raise RuntimeError(f"Unable to determine whether branch '{branch}' already has an open PR") title = "chore: update spec.lock (no guideline impact)" body = ( "Updates `src/spec.lock` after confirming the audit reported no affected guidelines.\n\n" @@ -84,7 +102,10 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: labels = bot.parse_issue_labels() if bot.FLS_AUDIT_LABEL not in labels: return "❌ This command is only available on issues labeled `fls-audit`.", False - if not bot.check_user_permission(comment_author, "triage"): + permission_status = bot.get_user_permission_status(comment_author, "triage") + if permission_status == "unavailable": + return "❌ Unable to verify triage permissions right now; refusing to run this command.", False + if permission_status != "granted": return "❌ You must have triage permissions to run this command.", False repo_root = get_target_repo_root() diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 4dd9bfdec..53b7233be 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -7,6 +7,9 @@ from datetime import datetime, timezone from pathlib import Path +from .automation import ( + find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status, +) from .config import AssignmentAttempt from .guidance import get_issue_guidance, get_pr_guidance @@ -110,13 +113,22 @@ def _apply_assignment_side_effects( return assignment_attempt, failure_comment +def _current_assignees_or_error(bot, issue_number: int) -> tuple[list[str] | None, str | None]: + current_assignees = bot.get_issue_assignees(issue_number) + if current_assignees is None: + return None, "❌ Unable to determine current assignees/reviewers from GitHub; refusing to continue." + return current_assignees, None + + def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str, reason: str | None) -> tuple[str, bool]: issue_data = bot.ensure_review_entry(state, issue_number, create=True) if issue_data is None: return "❌ Unable to load review state.", False passed_reviewer = issue_data.get("current_reviewer") if not passed_reviewer: - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False passed_reviewer = current_assignees[0] if current_assignees else None if not passed_reviewer: return "❌ No reviewer is currently assigned to pass.", False @@ -195,7 +207,9 @@ def handle_pass_until_command(bot, state: dict, issue_number: int, comment_autho issue_data = state["active_reviews"][issue_key] if isinstance(issue_data, dict): tracked_reviewer = issue_data.get("current_reviewer") - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False is_current_reviewer = ((tracked_reviewer and tracked_reviewer.lower() == comment_author.lower()) or comment_author.lower() in [a.lower() for a in current_assignees]) reassigned_msg = "" if is_current_reviewer: @@ -321,16 +335,10 @@ def get_default_branch(bot) -> str: def find_open_pr_for_branch(bot, branch: str) -> dict | None: - owner = os.environ.get("REPO_OWNER", "").strip() - branch = branch.strip() - if not owner or not branch: + status, pr = automation_find_open_pr_for_branch_status(bot, branch) + if status != "found": return None - response = bot.github_api("GET", f"pulls?state=open&head={owner}:{branch}") - if isinstance(response, list) and response: - first = response[0] - if isinstance(first, dict): - return first - return None + return pr def resolve_workflow_run_pr_number(bot) -> int: @@ -351,9 +359,10 @@ def resolve_workflow_run_pr_number(bot) -> int: raise RuntimeError("Missing WORKFLOW_RUN_HEAD_SHA for workflow_run reconcile") if reconcile_head_sha != workflow_run_head_sha: raise RuntimeError("Workflow_run reconcile context SHA mismatch between artifact and workflow payload") - pull_request = bot.github_api("GET", f"pulls/{pr_number}") - if not isinstance(pull_request, dict): + response = bot.github_api_request("GET", f"pulls/{pr_number}", retry_policy="idempotent_read") + if not response.ok or not isinstance(response.payload, dict): raise RuntimeError(f"Failed to fetch pull request #{pr_number} during workflow_run reconcile") + pull_request = response.payload head = pull_request.get("head") pull_request_head_sha = "" if isinstance(head, dict): @@ -369,9 +378,11 @@ def resolve_workflow_run_pr_number(bot) -> int: def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None: - existing = bot.find_open_pr_for_branch(branch) - if existing: + lookup_status, existing = automation_find_open_pr_for_branch_status(bot, branch) + if lookup_status == "found": return existing + if lookup_status == "unavailable": + raise RuntimeError(f"Unable to determine whether branch '{branch}' already has an open PR") title = "chore: update spec.lock (no guideline impact)" body = "Updates `src/spec.lock` after confirming the audit reported no affected guidelines.\n\n" f"Closes #{issue_number}" response = bot.github_api("POST", "pulls", {"title": title, "head": branch, "base": base, "body": body}) @@ -386,7 +397,10 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: labels = bot.parse_issue_labels() if bot.FLS_AUDIT_LABEL not in labels: return "❌ This command is only available on issues labeled `fls-audit`.", False - if not bot.check_user_permission(comment_author, "triage"): + permission_status = bot.get_user_permission_status(comment_author, "triage") + if permission_status == "unavailable": + return "❌ Unable to verify triage permissions right now; refusing to run this command.", False + if permission_status != "granted": return "❌ You must have triage permissions to run this command.", False repo_root = get_target_repo_root() if bot.list_changed_files(repo_root): @@ -474,7 +488,9 @@ def handle_claim_command(bot, state: dict, issue_number: int, comment_author: st return (f"❌ @{comment_author} is not in the reviewer queue. Only Producers can claim reviews."), False if is_away: return (f"❌ @{comment_author} is currently marked as away. Please use `{bot.BOT_MENTION} /away YYYY-MM-DD` to update your return date first, or wait until your scheduled return."), False - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) issue_author = os.environ.get("ISSUE_AUTHOR", "") @@ -503,7 +519,10 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author: target_username = args[0].lstrip("@") reason = " ".join(args[1:]) if len(args) > 1 else None releasing_other = target_username.lower() != comment_author.lower() - if releasing_other and not bot.check_user_permission(comment_author, "triage"): + permission_status = bot.get_user_permission_status(comment_author, "triage") + if permission_status == "unavailable": + return "❌ Unable to verify triage permissions right now; refusing to continue.", False + if releasing_other and permission_status != "granted": return (f"❌ @{comment_author} does not have permission to release other reviewers. Triage access or higher is required."), False else: target_username = comment_author @@ -516,7 +535,9 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author: if isinstance(issue_data, dict): tracked_reviewer = issue_data.get("current_reviewer") assignment_method = issue_data.get("assignment_method") - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False is_tracked = tracked_reviewer and tracked_reviewer.lower() == target_username.lower() is_assigned = target_username.lower() in [assignee.lower() for assignee in current_assignees] if not is_tracked and not is_assigned: @@ -555,7 +576,9 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) -> if entry["github"].lower() == username.lower(): return_date = entry.get("return_date", "unknown") return (f"⚠️ @{username} is currently marked as away until {return_date}. Consider assigning someone else or waiting."), False - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) issue_author = os.environ.get("ISSUE_AUTHOR", "") @@ -577,7 +600,9 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) -> def handle_assign_from_queue_command(bot, state: dict, issue_number: int) -> tuple[str, bool]: - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) + if assignee_error: + return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) issue_author = os.environ.get("ISSUE_AUTHOR", "") diff --git a/scripts/reviewer_bot_lib/config.py b/scripts/reviewer_bot_lib/config.py index 428455d84..13dab206e 100644 --- a/scripts/reviewer_bot_lib/config.py +++ b/scripts/reviewer_bot_lib/config.py @@ -197,11 +197,14 @@ def get_commands_help() -> str: @dataclass class GitHubApiResult: - status_code: int + status_code: int | None payload: Any headers: dict[str, str] text: str ok: bool + failure_kind: str | None = None + retry_attempts: int = 0 + transport_error: str | None = None @dataclass diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 2c3d69c76..4812f9011 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -28,6 +28,8 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, *, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: ... def github_api(self, method: str, endpoint: str, data: dict | None = None) -> Any | None: ... @@ -37,6 +39,8 @@ def github_graphql_request( variables: dict | None = None, *, token: str | None = None, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: ... def github_graphql( @@ -47,6 +51,7 @@ def github_graphql( token: str | None = None, ) -> Any | None: ... def request_reviewer_assignment(self, issue_number: int, username: str) -> AssignmentAttempt: ... + def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: ... def remove_assignee(self, issue_number: int, username: str) -> bool: ... def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: ... @@ -71,6 +76,8 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, *, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: ... def get_state_issue(self) -> dict | None: ... @@ -119,6 +126,8 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, *, + retry_policy: str = "none", + timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: ... def get_lock_ref_display(self) -> str: ... diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 2a57973e0..3e8816be8 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -17,6 +17,81 @@ ) from .context import GitHubTransportContext +RETRY_POLICY_NONE = "none" +RETRY_POLICY_IDEMPOTENT_READ = "idempotent_read" + + +def _should_retry_status(status_code: int | None) -> bool: + return status_code == 429 or (status_code is not None and status_code >= 500) + + +def _classify_failure(status_code: int | None, *, invalid_payload: bool = False, transport_error: bool = False) -> str | None: + if invalid_payload: + return "invalid_payload" + if transport_error: + return "transport_error" + if status_code is None: + return None + if status_code == 404: + return "not_found" + if status_code == 401: + return "unauthorized" + if status_code == 403: + return "forbidden" + if status_code == 429: + return "rate_limited" + if status_code >= 500: + return "server_error" + return None + + +def _retry_delay(base_seconds: float, retry_attempt: int) -> float: + bounded_base = min(base_seconds * (2 ** max(retry_attempt - 1, 0)), 8.0) + return bounded_base + random.uniform(0, bounded_base) + + +def _validate_rest_retry_policy(method: str, retry_policy: str) -> None: + if retry_policy == RETRY_POLICY_NONE: + return + if retry_policy != RETRY_POLICY_IDEMPOTENT_READ: + raise ValueError(f"Unsupported retry policy: {retry_policy}") + if method.upper() != "GET": + raise ValueError("idempotent_read retry policy is only valid for REST GET requests") + + +def _validate_graphql_retry_policy(query: str, retry_policy: str) -> None: + if retry_policy == RETRY_POLICY_NONE: + return + if retry_policy != RETRY_POLICY_IDEMPOTENT_READ: + raise ValueError(f"Unsupported retry policy: {retry_policy}") + stripped = query.lstrip() + if stripped.startswith("mutation"): + raise ValueError("idempotent_read retry policy is only valid for GraphQL queries") + + +def _build_result( + bot: GitHubTransportContext, + *, + status_code: int | None, + payload, + headers: dict[str, str] | None, + text: str, + ok: bool, + failure_kind: str | None, + retry_attempts: int, + transport_error: str | None = None, +): + return bot.GitHubApiResult( + status_code=status_code, + payload=payload, + headers=headers or {}, + text=text, + ok=ok, + failure_kind=failure_kind, + retry_attempts=retry_attempts, + transport_error=transport_error, + ) + def get_github_token() -> str: token = os.environ.get("GITHUB_TOKEN") @@ -42,8 +117,11 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, *, + retry_policy: str = RETRY_POLICY_NONE, + timeout_seconds: float | None = None, suppress_error_log: bool = False, ): + _validate_rest_retry_policy(method, retry_policy) token = bot.get_github_token() repo = f"{os.environ['REPO_OWNER']}/{os.environ['REPO_NAME']}" url = f"https://api.github.com/repos/{repo}/{endpoint}" @@ -56,27 +134,72 @@ def github_api_request( if extra_headers: headers.update(extra_headers) - response = requests.request(method, url, headers=headers, json=data) - - payload = None - if response.content: + retry_attempts = 0 + max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) + for attempt in range(1, max_attempts + 1): try: - payload = response.json() - except ValueError: - payload = None + response = requests.request(method, url, headers=headers, json=data, timeout=timeout_seconds) + except requests.RequestException as exc: + failure_kind = _classify_failure(None, transport_error=True) + if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and attempt < max_attempts: + retry_attempts += 1 + time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + continue + if not suppress_error_log: + print(f"GitHub API transport error: {exc}", file=sys.stderr) + return _build_result( + bot, + status_code=None, + payload=None, + headers={}, + text="", + ok=False, + failure_kind=failure_kind, + retry_attempts=retry_attempts, + transport_error=str(exc), + ) - ok = response.status_code < 400 - if not ok and not suppress_error_log: - print(f"GitHub API error: {response.status_code} - {response.text}", file=sys.stderr) + payload = None + if response.content: + try: + payload = response.json() + except ValueError: + payload = None + + ok = response.status_code < 400 + normalized_headers = {key.lower(): value for key, value in response.headers.items()} + if ok: + return _build_result( + bot, + status_code=response.status_code, + payload=payload, + headers=normalized_headers, + text=response.text, + ok=True, + failure_kind=None, + retry_attempts=retry_attempts, + ) - normalized_headers = {key.lower(): value for key, value in response.headers.items()} - return bot.GitHubApiResult( - status_code=response.status_code, - payload=payload, - headers=normalized_headers, - text=response.text, - ok=ok, - ) + failure_kind = _classify_failure(response.status_code) + if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and _should_retry_status(response.status_code) and attempt < max_attempts: + retry_attempts += 1 + time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + continue + + if not suppress_error_log: + print(f"GitHub API error: {response.status_code} - {response.text}", file=sys.stderr) + return _build_result( + bot, + status_code=response.status_code, + payload=payload, + headers=normalized_headers, + text=response.text, + ok=False, + failure_kind=failure_kind, + retry_attempts=retry_attempts, + ) + + raise AssertionError("unreachable") def github_api(bot: GitHubTransportContext, method: str, endpoint: str, data: dict | None = None): @@ -94,43 +217,97 @@ def github_graphql_request( variables: dict | None = None, *, token: str | None = None, + retry_policy: str = RETRY_POLICY_NONE, + timeout_seconds: float | None = None, suppress_error_log: bool = False, ): + _validate_graphql_retry_policy(query, retry_policy) graphql_token = token or bot.get_github_graphql_token() headers = { "Authorization": f"Bearer {graphql_token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } - response = requests.post( - "https://api.github.com/graphql", - headers=headers, - json={"query": query, "variables": variables or {}}, - ) - payload = None - if response.content: + retry_attempts = 0 + max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) + for attempt in range(1, max_attempts + 1): try: - payload = response.json() - except ValueError: - payload = None - - graphql_errors = payload.get("errors") if isinstance(payload, dict) else None - ok = response.status_code < 400 and not graphql_errors - if not ok and not suppress_error_log: - details = response.text - if graphql_errors: - details = json.dumps(graphql_errors, sort_keys=True) - print(f"GitHub GraphQL error: {response.status_code} - {details}", file=sys.stderr) - - normalized_headers = {key.lower(): value for key, value in response.headers.items()} - return bot.GitHubApiResult( - status_code=response.status_code, - payload=payload, - headers=normalized_headers, - text=response.text, - ok=ok, - ) + response = requests.post( + "https://api.github.com/graphql", + headers=headers, + json={"query": query, "variables": variables or {}}, + timeout=timeout_seconds, + ) + except requests.RequestException as exc: + failure_kind = _classify_failure(None, transport_error=True) + if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and attempt < max_attempts: + retry_attempts += 1 + time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + continue + if not suppress_error_log: + print(f"GitHub GraphQL transport error: {exc}", file=sys.stderr) + return _build_result( + bot, + status_code=None, + payload=None, + headers={}, + text="", + ok=False, + failure_kind=failure_kind, + retry_attempts=retry_attempts, + transport_error=str(exc), + ) + + payload = None + invalid_payload = False + if response.content: + try: + payload = response.json() + except ValueError: + invalid_payload = True + + graphql_errors = payload.get("errors") if isinstance(payload, dict) else None + ok = response.status_code < 400 and not graphql_errors and not invalid_payload + normalized_headers = {key.lower(): value for key, value in response.headers.items()} + if ok: + return _build_result( + bot, + status_code=response.status_code, + payload=payload, + headers=normalized_headers, + text=response.text, + ok=True, + failure_kind=None, + retry_attempts=retry_attempts, + ) + + failure_kind = _classify_failure( + response.status_code, + invalid_payload=invalid_payload or bool(graphql_errors), + ) + if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and _should_retry_status(response.status_code) and attempt < max_attempts: + retry_attempts += 1 + time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + continue + + if not suppress_error_log: + details = response.text + if graphql_errors: + details = json.dumps(graphql_errors, sort_keys=True) + print(f"GitHub GraphQL error: {response.status_code} - {details}", file=sys.stderr) + return _build_result( + bot, + status_code=response.status_code, + payload=payload, + headers=normalized_headers, + text=response.text, + ok=False, + failure_kind=failure_kind, + retry_attempts=retry_attempts, + ) + + raise AssertionError("unreachable") def github_graphql( @@ -164,8 +341,12 @@ def add_label(bot: GitHubTransportContext, issue_number: int, label: str) -> boo def remove_label(bot: GitHubTransportContext, issue_number: int, label: str) -> bool: - bot.github_api("DELETE", f"issues/{issue_number}/labels/{quote(label, safe='')}") - return True + response = bot.github_api_request( + "DELETE", + f"issues/{issue_number}/labels/{quote(label, safe='')}", + suppress_error_log=True, + ) + return response.status_code in {200, 204, 404} def add_label_with_status(bot: GitHubTransportContext, issue_number: int, label: str) -> bool: @@ -312,15 +493,27 @@ def get_assignment_failure_comment(bot: GitHubTransportContext, reviewer: str, a return None -def get_issue_assignees(bot: GitHubTransportContext, issue_number: int) -> list[str]: +def get_issue_assignees(bot: GitHubTransportContext, issue_number: int) -> list[str] | None: is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" if is_pr: - result = bot.github_api("GET", f"pulls/{issue_number}") - if result and "requested_reviewers" in result: + try: + response = bot.github_api_request("GET", f"pulls/{issue_number}", retry_policy=RETRY_POLICY_IDEMPOTENT_READ) + result = response.payload + if not response.ok: + return None + except SystemExit: + result = bot.github_api("GET", f"pulls/{issue_number}") + if isinstance(result, dict) and "requested_reviewers" in result: return [reviewer["login"] for reviewer in result["requested_reviewers"]] else: - result = bot.github_api("GET", f"issues/{issue_number}") - if result and "assignees" in result: + try: + response = bot.github_api_request("GET", f"issues/{issue_number}", retry_policy=RETRY_POLICY_IDEMPOTENT_READ) + result = response.payload + if not response.ok: + return None + except SystemExit: + result = bot.github_api("GET", f"issues/{issue_number}") + if isinstance(result, dict) and "assignees" in result: return [assignee["login"] for assignee in result["assignees"]] return [] @@ -357,9 +550,42 @@ def unassign_reviewer(bot: GitHubTransportContext, issue_number: int, username: return bot.remove_assignee(issue_number, username) -def check_user_permission(bot: GitHubTransportContext, username: str, required_permission: str = "triage") -> bool: - result = bot.github_api("GET", f"collaborators/{username}/permission") - if not result: - return False +def get_user_permission_status( + bot: GitHubTransportContext, + username: str, + required_permission: str = "triage", +) -> str: + try: + response = bot.github_api_request( + "GET", + f"collaborators/{username}/permission", + retry_policy=RETRY_POLICY_IDEMPOTENT_READ, + ) + except SystemExit: + fallback = getattr(bot, "check_user_permission", None) + if callable(fallback) and getattr(fallback, "__name__", "") != "check_user_permission": + result = fallback(username, required_permission) + if result is None: + return "unavailable" + return "granted" if result else "denied" + return "unavailable" + if not response.ok: + return "unavailable" + result = response.payload + if not isinstance(result, dict): + return "unavailable" permissions = result.get("user", {}).get("permissions", {}) - return permissions.get(required_permission, False) + if not isinstance(permissions, dict): + return "unavailable" + return "granted" if permissions.get(required_permission, False) else "denied" + + +def check_user_permission( + bot: GitHubTransportContext, + username: str, + required_permission: str = "triage", +) -> bool | None: + status = get_user_permission_status(bot, username, required_permission) + if status == "unavailable": + return None + return status == "granted" diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index 9bd2008c6..4313dbc2a 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -196,7 +196,12 @@ def parse_lock_metadata_from_lock_commit_message(bot: LeaseLockContext, message: def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: lock_ref = get_lock_ref_name(bot) - response = bot.github_api_request("GET", f"git/ref/{lock_ref}", suppress_error_log=True) + response = bot.github_api_request( + "GET", + f"git/ref/{lock_ref}", + retry_policy="idempotent_read", + suppress_error_log=True, + ) if response.status_code == 200: ref_sha = extract_ref_sha(response.payload) if not ref_sha: @@ -212,6 +217,7 @@ def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: branch_response = bot.github_api_request( "GET", f"git/ref/heads/{default_branch}", + retry_policy="idempotent_read", suppress_error_log=True, ) if branch_response.status_code != 200: @@ -236,7 +242,12 @@ def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: f"{get_lock_ref_display(bot)} (status {create_response.status_code}): {create_response.text}" ) - refresh_response = bot.github_api_request("GET", f"git/ref/{lock_ref}", suppress_error_log=True) + refresh_response = bot.github_api_request( + "GET", + f"git/ref/{lock_ref}", + retry_policy="idempotent_read", + suppress_error_log=True, + ) if refresh_response.status_code != 200: raise RuntimeError( "Unable to read reviewer-bot lock ref after create " @@ -251,7 +262,12 @@ def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: def get_lock_ref_snapshot(bot: LeaseLockContext) -> tuple[str, str, dict]: ref_sha = ensure_lock_ref_exists(bot) - commit_response = bot.github_api_request("GET", f"git/commits/{ref_sha}", suppress_error_log=True) + commit_response = bot.github_api_request( + "GET", + f"git/commits/{ref_sha}", + retry_policy="idempotent_read", + suppress_error_log=True, + ) if commit_response.status_code != 200: raise RuntimeError( f"Failed to read lock commit {ref_sha} (status {commit_response.status_code}): {commit_response.text}" diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index bad3765ae..ceaf4b946 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -5,11 +5,23 @@ import hashlib import json import os +from dataclasses import dataclass from datetime import datetime, timezone from .guidance import get_fls_audit_guidance, get_issue_guidance, get_pr_guidance +@dataclass(frozen=True) +class HeadObservationRepairResult: + changed: bool + outcome: str + failure_kind: str | None = None + reason: str | None = None + + def __bool__(self) -> bool: + return self.changed + + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -69,6 +81,8 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: if tracked_reviewer: return False current_assignees = bot.get_issue_assignees(issue_number) + if current_assignees is None: + raise RuntimeError(f"Unable to determine assignees for #{issue_number}") if current_assignees: return False labels_json = os.environ.get("ISSUE_LABELS", "[]") @@ -191,24 +205,69 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: return changed -def maybe_record_head_observation_repair(bot, issue_number: int, review_data: dict) -> bool: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") +def maybe_record_head_observation_repair(bot, issue_number: int, review_data: dict) -> HeadObservationRepairResult: + try: + response = bot.github_api_request("GET", f"pulls/{issue_number}", retry_policy="idempotent_read") + except SystemExit: + payload = bot.github_api("GET", f"pulls/{issue_number}") + if not isinstance(payload, dict): + return HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="unavailable", + reason="pull_request_unavailable", + ) + response = bot.GitHubApiResult( + status_code=200, + payload=payload, + headers={}, + text="", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if not response.ok: + if response.failure_kind == "not_found": + return HeadObservationRepairResult( + changed=False, + outcome="skipped_not_found", + failure_kind=response.failure_kind, + reason=f"pull_request_{response.failure_kind}", + ) + return HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind=response.failure_kind, + reason="pull_request_unavailable", + ) + pull_request = response.payload if not isinstance(pull_request, dict): - raise RuntimeError(f"Failed to fetch live PR #{issue_number} for head observation repair") + return HeadObservationRepairResult( + changed=False, + outcome="invalid_live_payload", + failure_kind="invalid_payload", + reason="pull_request_payload_invalid", + ) if str(pull_request.get("state", "")).lower() != "open": - return False + return HeadObservationRepairResult(changed=False, outcome="skipped_not_open") head = pull_request.get("head") head_sha = head.get("sha") if isinstance(head, dict) else None if not isinstance(head_sha, str) or not head_sha.strip(): - raise RuntimeError(f"Pull request #{issue_number} is missing a usable head SHA") + return HeadObservationRepairResult( + changed=False, + outcome="invalid_live_payload", + failure_kind="invalid_payload", + reason="pull_request_head_unavailable", + ) head_sha = head_sha.strip() current_head = review_data.get("active_head_sha") if current_head == head_sha: - return False + return HeadObservationRepairResult(changed=False, outcome="unchanged") contributor_revision = review_data.get("contributor_revision", {}).get("accepted") if isinstance(contributor_revision, dict) and contributor_revision.get("reviewed_head_sha") == head_sha: review_data["active_head_sha"] = head_sha - return False + return HeadObservationRepairResult(changed=True, outcome="changed") changed = bot.reviews_module.accept_channel_event( review_data, "contributor_revision", @@ -223,7 +282,7 @@ def maybe_record_head_observation_repair(bot, issue_number: int, review_data: di review_data["review_completed_at"] = None review_data["review_completed_by"] = None review_data["review_completion_source"] = None - return changed + return HeadObservationRepairResult(changed=changed, outcome="changed" if changed else "unchanged") def handle_closed_event(bot, state: dict) -> bool: diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 4f7e2dd1e..1c58c97e7 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -25,6 +25,39 @@ def _now_iso(bot) -> str: return bot.datetime.now(bot.timezone.utc).isoformat() +def _clear_maintenance_repair_marker(review_data: dict, phase: str) -> bool: + marker = review_data.get("repair_needed") + if not isinstance(marker, dict): + return False + if marker.get("kind") != "live_read_failure": + return False + if marker.get("phase") != phase: + return False + review_data["repair_needed"] = None + return True + + +def _record_maintenance_repair_marker( + bot, + review_data: dict, + *, + phase: str, + reason: str, + failure_kind: str | None, +) -> bool: + marker = { + "kind": "live_read_failure", + "phase": phase, + "reason": reason, + "failure_kind": failure_kind, + "recorded_at": _now_iso(bot), + } + if review_data.get("repair_needed") == marker: + return False + review_data["repair_needed"] = marker + return True + + def status_projection_repair_needed(bot, state: dict) -> bool: current_epoch = state.get("status_projection_epoch") return current_epoch != bot.STATUS_PROJECTION_EPOCH @@ -152,12 +185,58 @@ def handle_scheduled_check(bot, state: dict) -> bool: issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict) or not isinstance(issue_snapshot.get("pull_request"), dict): continue - if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data): + try: + if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data): + changed = True + bot.collect_touched_item(issue_number) + if _clear_maintenance_repair_marker(review_data, "review_repair"): + changed = True + except Exception as exc: + print( + f"WARNING: Scheduled repair failed for #{issue_number} during review_repair: {exc}", + file=bot.sys.stderr, + ) + if _record_maintenance_repair_marker( + bot, + review_data, + phase="review_repair", + reason=str(exc), + failure_kind=None, + ): + changed = True + continue + + try: + repair_result = maybe_record_head_observation_repair(bot, issue_number, review_data) + except Exception as exc: + print( + f"WARNING: Scheduled repair failed for #{issue_number} during head_observation_repair: {exc}", + file=bot.sys.stderr, + ) + if _record_maintenance_repair_marker( + bot, + review_data, + phase="head_observation_repair", + reason=str(exc), + failure_kind=None, + ): + changed = True + continue + + if repair_result.changed: changed = True bot.collect_touched_item(issue_number) - if maybe_record_head_observation_repair(bot, issue_number, review_data): + if repair_result.outcome in {"skipped_unavailable", "skipped_not_found", "invalid_live_payload"}: + if _record_maintenance_repair_marker( + bot, + review_data, + phase="head_observation_repair", + reason=repair_result.reason or repair_result.outcome, + failure_kind=repair_result.failure_kind, + ): + changed = True + elif _clear_maintenance_repair_marker(review_data, "head_observation_repair"): changed = True - bot.collect_touched_item(issue_number) overdue_reviews = check_overdue_reviews(bot, state) if not overdue_reviews: return changed diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index 4e2ecd0d3..4d5a3c9e2 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -18,13 +18,19 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: if review_data.get("review_completed_at"): continue + if review_data.get("transition_notice_sent_at"): + continue + current_reviewer = review_data.get("current_reviewer") if not current_reviewer: continue issue_number = int(issue_key) issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) - if isinstance(issue_snapshot, dict) and isinstance(issue_snapshot.get("pull_request"), dict): + if not isinstance(issue_snapshot, dict): + print(f"WARNING: Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable") + continue + if isinstance(issue_snapshot.get("pull_request"), dict): response_state = bot.reviews_module.compute_reviewer_response_state( bot, issue_number, @@ -53,11 +59,6 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: continue transition_warning_sent = review_data.get("transition_warning_sent") - transition_notice_sent_at = review_data.get("transition_notice_sent_at") - - if transition_notice_sent_at: - continue - if transition_warning_sent: try: warning_dt = bot.datetime.fromisoformat(transition_warning_sent.replace("Z", "+00:00")) @@ -169,7 +170,8 @@ def handle_overdue_review_warning(bot, state: dict, issue_number: int, reviewer: _Life happens! If you're dealing with something, just let us know._""" - bot.post_comment(issue_number, warning_message) + if not bot.post_comment(issue_number, warning_message): + return False now = bot.datetime.now(bot.timezone.utc).isoformat() review_data["transition_warning_sent"] = now diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index b25759b95..98e199d0e 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -21,6 +21,30 @@ def _now_iso(bot) -> str: return bot.datetime.now(bot.timezone.utc).isoformat() +class ReconcileReadError(RuntimeError): + def __init__(self, message: str, *, failure_kind: str | None = None): + super().__init__(message) + self.failure_kind = failure_kind + + +def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: + try: + response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") + except SystemExit: + payload = bot.github_api("GET", endpoint) + if not isinstance(payload, dict): + raise ReconcileReadError(f"{label} unavailable", failure_kind="unavailable") + return payload + if not response.ok: + failure_kind = response.failure_kind + if failure_kind == "not_found": + raise ReconcileReadError(f"{label} not found", failure_kind=failure_kind) + raise ReconcileReadError(f"{label} unavailable", failure_kind=failure_kind) + if not isinstance(response.payload, dict): + raise ReconcileReadError(f"{label} payload invalid", failure_kind="invalid_payload") + return response.payload + + def _ensure_source_event_key(review_data: dict, source_event_key: str, payload: dict | None = None) -> None: review_data.setdefault("deferred_gaps", {}) if payload is None: @@ -47,12 +71,10 @@ def _was_reconciled_source_event(review_data: dict, source_event_key: str) -> bo def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dict) -> bool: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") - if not isinstance(pull_request, dict): - raise RuntimeError(f"Failed to fetch pull request #{issue_number}") + pull_request = _read_reconcile_object(bot, f"pulls/{issue_number}", label=f"pull request #{issue_number}") reviews = bot.get_pull_request_reviews(issue_number) if reviews is None: - raise RuntimeError(f"Failed to fetch live reviews for PR #{issue_number}") + raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") refresh_reviewer_review_from_live_preferred_review( bot, issue_number, @@ -61,9 +83,19 @@ def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dic reviews=reviews, actor=review_data.get("current_reviewer"), ) - completion, _ = bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data, pull_request=pull_request, reviews=reviews) - if completion is None: - raise RuntimeError(f"Unable to rebuild approval state for PR #{issue_number}") + approval_result = bot.reviews_module.rebuild_pr_approval_state_result( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if not approval_result.get("ok"): + raise ReconcileReadError( + f"Unable to rebuild approval state for PR #{issue_number}: {approval_result.get('reason')}", + failure_kind=str(approval_result.get("failure_kind") or "unavailable"), + ) + completion = approval_result["completion"] return bool(completion.get("completed")) @@ -218,9 +250,7 @@ def _set_env_if_present(name: str, value) -> None: def _hydrate_reconcile_pr_context(bot, pr_number: int) -> dict: - pull_request = bot.github_api("GET", f"pulls/{pr_number}") - if not isinstance(pull_request, dict): - raise RuntimeError(f"Failed to fetch live PR #{pr_number} for reconcile context") + pull_request = _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number} for reconcile context") author = pull_request.get("user") if not isinstance(author, dict): raise RuntimeError(f"Live PR #{pr_number} is missing author metadata") @@ -394,12 +424,30 @@ def _reconcile_deferred_comment( comment_created_at = str(payload.get("source_created_at")) classified = payload.get("comment_class") source_freshness_eligible = classified in {"plain_text", "command_plus_text"} and bool(payload.get("has_non_command_text")) - live_comment = bot.github_api("GET", live_comment_endpoint) - if not isinstance(live_comment, dict): + try: + live_comment = _read_reconcile_object(bot, live_comment_endpoint, label=f"deferred comment {comment_id}") + except ReconcileReadError as exc: changed = False if source_freshness_eligible: changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) - _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} is no longer visible; source-time freshness only may be preserved. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") + if exc.failure_kind == "not_found": + summary = ( + f"Deferred comment {comment_id} is no longer visible; source-time freshness only may be preserved. " + f"See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." + ) + else: + summary = ( + f"Deferred comment {comment_id} could not be validated from live GitHub data " + f"({exc.failure_kind or 'unavailable'}); replay suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." + ) + _update_deferred_gap( + bot, + review_data, + payload, + "reconcile_failed_closed", + summary, + failure_kind=exc.failure_kind, + ) return changed _hydrate_reconcile_comment_context(live_comment, payload) live_body = live_comment.get("body") @@ -424,7 +472,15 @@ def _reconcile_deferred_comment( return changed -def _update_deferred_gap(bot, review_data: dict, payload: dict, reason: str, diagnostic_summary: str) -> None: +def _update_deferred_gap( + bot, + review_data: dict, + payload: dict, + reason: str, + diagnostic_summary: str, + *, + failure_kind: str | None = None, +) -> None: source_event_key = str(payload.get("source_event_key", "")) if not source_event_key: return @@ -447,6 +503,7 @@ def _update_deferred_gap(bot, review_data: dict, payload: dict, reason: str, dia "last_checked_at": _now_iso(bot), "operator_action_required": True, "diagnostic_summary": diagnostic_summary, + "failure_kind": failure_kind, } ) review_data["deferred_gaps"][source_event_key] = existing @@ -563,10 +620,14 @@ def handle_workflow_run_event(bot, state: dict) -> bool: review_id = review_id_value if source_event_key != f"pull_request_review:{review_id}": raise RuntimeError("Deferred review-submitted artifact source_event_key mismatch") - live_review = bot.github_api("GET", f"pulls/{pr_number}/reviews/{review_id}") - live_pr = bot.github_api("GET", f"pulls/{pr_number}") - if not isinstance(live_pr, dict): - raise RuntimeError(f"Failed to fetch live PR #{pr_number}") + try: + live_review = _read_reconcile_object(bot, f"pulls/{pr_number}/reviews/{review_id}", label=f"live review #{review_id}") + except ReconcileReadError as exc: + if exc.failure_kind == "not_found": + live_review = None + else: + raise + _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number}") live_commit_id = None live_submitted_at = payload.get("source_submitted_at") live_state = payload.get("source_review_state") @@ -577,7 +638,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: else: live_commit_id = payload.get("source_commit_id") actor = str(payload.get("actor_login", "")) - changed = bot.maybe_record_head_observation_repair(pr_number, review_data) + state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): bot.reviews_module.accept_channel_event( review_data, @@ -589,10 +650,12 @@ def handle_workflow_run_event(bot, state: dict) -> bool: source_precedence=1, ) bot.reviews_module.record_reviewer_activity(review_data, live_submitted_at) - _record_review_rebuild(bot, state, pr_number, review_data) + state_changed = True + if _record_review_rebuild(bot, state, pr_number, review_data): + state_changed = True _mark_reconciled_source_event(review_data, source_event_key) _clear_source_event_key(review_data, source_event_key) - return changed or True + return state_changed if event_name == "pull_request_review" and event_action == "dismissed": _validate_deferred_review_artifact(payload) @@ -609,12 +672,21 @@ def handle_workflow_run_event(bot, state: dict) -> bool: timestamp=_now_iso(bot), dismissal_only=True, ) - bot.maybe_record_head_observation_repair(pr_number, review_data) - _record_review_rebuild(bot, state, pr_number, review_data) + state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed + if _record_review_rebuild(bot, state, pr_number, review_data): + state_changed = True _mark_reconciled_source_event(review_data, source_event_key) _clear_source_event_key(review_data, source_event_key) return True except RuntimeError as exc: - _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"{exc} See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") + failure_kind = exc.failure_kind if isinstance(exc, ReconcileReadError) else None + _update_deferred_gap( + bot, + review_data, + payload, + "reconcile_failed_closed", + f"{exc} See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.", + failure_kind=failure_kind, + ) raise raise RuntimeError("Unsupported deferred workflow_run payload") diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 661d8718e..f0c1c1a3b 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -21,6 +21,78 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() +def _projection_failure(reason: str, failure_kind: str | None = None) -> dict[str, object]: + return {"ok": False, "reason": reason, "failure_kind": failure_kind} + + +def _pull_request_read_result(bot, issue_number: int, pull_request: dict | None = None) -> dict[str, object]: + if pull_request is not None: + if isinstance(pull_request, dict): + return {"ok": True, "pull_request": pull_request} + return _projection_failure("pull_request_unavailable", "invalid_payload") + try: + response = bot.github_api_request("GET", f"pulls/{issue_number}", retry_policy="idempotent_read") + except SystemExit: + payload = bot.github_api("GET", f"pulls/{issue_number}") + if isinstance(payload, dict): + return {"ok": True, "pull_request": payload} + return _projection_failure("pull_request_unavailable") + if not response.ok: + if response.failure_kind == "not_found": + return _projection_failure("pull_request_not_found", response.failure_kind) + return _projection_failure("pull_request_unavailable", response.failure_kind) + if not isinstance(response.payload, dict): + return _projection_failure("pull_request_unavailable", "invalid_payload") + return {"ok": True, "pull_request": response.payload} + + +def get_pull_request_reviews_result(bot, issue_number: int, reviews: list[dict] | None = None) -> dict[str, object]: + if reviews is not None: + return {"ok": True, "reviews": reviews} + collected_reviews: list[dict] = [] + page = 1 + while True: + try: + response = bot.github_api_request( + "GET", + f"pulls/{issue_number}/reviews?per_page=100&page={page}", + retry_policy="idempotent_read", + ) + except SystemExit: + fallback_loader = getattr(bot, "get_pull_request_reviews", None) + if callable(fallback_loader) and getattr(fallback_loader, "__name__", "") != "get_pull_request_reviews": + fallback_reviews = fallback_loader(issue_number) + if not isinstance(fallback_reviews, list): + return _projection_failure("reviews_unavailable") + return {"ok": True, "reviews": fallback_reviews} + payload = bot.github_api("GET", f"pulls/{issue_number}/reviews?per_page=100&page={page}") + if not isinstance(payload, list): + return _projection_failure("reviews_unavailable") + page_reviews = [review for review in payload if isinstance(review, dict)] + collected_reviews.extend(page_reviews) + if len(payload) < 100: + return {"ok": True, "reviews": collected_reviews} + page += 1 + continue + if not response.ok: + return _projection_failure("reviews_unavailable", response.failure_kind) + payload = response.payload + if not isinstance(payload, list): + return _projection_failure("reviews_unavailable", "invalid_payload") + page_reviews = [review for review in payload if isinstance(review, dict)] + collected_reviews.extend(page_reviews) + if len(payload) < 100: + return {"ok": True, "reviews": collected_reviews} + page += 1 + + +def _permission_status(bot, username: str, permission: str) -> str: + status = bot.get_user_permission_status(username, permission) + if status not in {"granted", "denied", "unavailable"}: + return "unavailable" + return status + + def parse_github_timestamp(value: str | None) -> datetime | None: if not isinstance(value, str) or not value: return None @@ -506,7 +578,10 @@ def mark_review_complete(state: dict, issue_number: int, reviewer: str | None, s def is_triage_or_higher(bot, username: str) -> bool: - return bot.check_user_permission(username, "triage") + status = _permission_status(bot, username, "triage") + if status == "unavailable": + raise RuntimeError(f"Unable to determine triage permission for @{username}") + return status == "granted" def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) -> bool: @@ -554,19 +629,11 @@ def satisfy_mandatory_approver_requirement(bot, state: dict, issue_number: int, def get_pull_request_reviews(bot, issue_number: int) -> list[dict] | None: - reviews: list[dict] = [] - page = 1 - while True: - result = bot.github_api("GET", f"pulls/{issue_number}/reviews?per_page=100&page={page}") - if result is None: - return None - if not isinstance(result, list): - return reviews - page_reviews = [review for review in result if isinstance(review, dict)] - reviews.extend(page_reviews) - if len(result) < 100: - return reviews - page += 1 + result = get_pull_request_reviews_result(bot, issue_number) + if not result.get("ok"): + return None + reviews = result.get("reviews") + return reviews if isinstance(reviews, list) else None def collapse_latest_reviews_by_login(reviews: list[dict]) -> dict[str, dict]: @@ -603,21 +670,41 @@ def rebuild_pr_approval_state( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> tuple[dict | None, dict | None]: + result = rebuild_pr_approval_state_result( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if not result.get("ok"): + return None, None + return result.get("completion"), result.get("write_approval") + + +def rebuild_pr_approval_state_result( + bot, + issue_number: int, + review_data: dict, + *, + pull_request: dict | None = None, + reviews: list[dict] | None = None, +) -> dict[str, object]: boundary = get_current_cycle_boundary(bot, review_data) if boundary is None: - return None, None - if pull_request is None: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") - if not isinstance(pull_request, dict): - return None, None + return _projection_failure("pull_request_unavailable") + pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) + if not pull_request_result.get("ok"): + return pull_request_result + pull_request = pull_request_result["pull_request"] head = pull_request.get("head") current_head = head.get("sha") if isinstance(head, dict) else None if not isinstance(current_head, str) or not current_head.strip(): - return None, None - if reviews is None: - reviews = bot.get_pull_request_reviews(issue_number) - if reviews is None: - return None, None + return _projection_failure("pull_request_head_unavailable", "invalid_payload") + reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) + if not reviews_result.get("ok"): + return reviews_result + reviews = reviews_result["reviews"] survivors: dict[str, dict] = {} for review in reviews: @@ -658,7 +745,7 @@ def rebuild_pr_approval_state( "qualifying_review_ids": [review.get("id") for review in approvals], } - permission_cache: dict[str, bool] = {} + permission_cache: dict[str, str] = {} has_write_approval = False write_approvers: list[str] = [] for review in approvals: @@ -667,8 +754,10 @@ def rebuild_pr_approval_state( continue cache_key = author.lower() if cache_key not in permission_cache: - permission_cache[cache_key] = bot.check_user_permission(author, "push") - if permission_cache[cache_key]: + permission_cache[cache_key] = _permission_status(bot, author, "push") + if permission_cache[cache_key] == "unavailable": + return _projection_failure("permission_unavailable") + if permission_cache[cache_key] == "granted": has_write_approval = True write_approvers.append(author) write_approval = { @@ -688,7 +777,7 @@ def rebuild_pr_approval_state( review_data["review_completed_at"] = None review_data["review_completed_by"] = None review_data["review_completion_source"] = None - return completion, write_approval + return {"ok": True, "completion": completion, "write_approval": write_approval} def pr_has_current_write_approval( @@ -798,6 +887,10 @@ def compute_reviewer_response_state( return {"state": "done", "reason": None} if not reviewer_comment and not reviewer_review: + reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) + if not reviews_result.get("ok"): + return {"state": "projection_failed", "reason": str(reviews_result.get("reason"))} + reviews = reviews_result["reviews"] preferred_live_review = get_preferred_current_reviewer_review_for_cycle( bot, issue_number, @@ -818,23 +911,25 @@ def compute_reviewer_response_state( "contributor_handoff": None, } - if pull_request is None: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") - if not isinstance(pull_request, dict): - return {"state": "projection_failed", "reason": "pull_request_unavailable"} + pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) + if not pull_request_result.get("ok"): + return {"state": "projection_failed", "reason": str(pull_request_result.get("reason"))} + pull_request = pull_request_result["pull_request"] head = pull_request.get("head") current_head = head.get("sha") if isinstance(head, dict) else None if not isinstance(current_head, str) or not current_head.strip(): return {"state": "projection_failed", "reason": "pull_request_head_unavailable"} review_data["active_head_sha"] = current_head - preferred_live_review = get_preferred_current_reviewer_review_for_cycle( - bot, - issue_number, - review_data, - pull_request=pull_request, - reviews=reviews, - ) + preferred_live_review = None + if reviewer_review is None or reviews is not None: + preferred_live_review = get_preferred_current_reviewer_review_for_cycle( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) if preferred_live_review is not None: reviewer_review = build_reviewer_review_record_from_live_review(preferred_live_review, actor=current_reviewer) @@ -875,9 +970,28 @@ def compute_reviewer_response_state( "contributor_handoff": contributor_handoff, } - completion, write_approval = rebuild_pr_approval_state(bot, issue_number, review_data, pull_request=pull_request, reviews=reviews) - if completion is None or write_approval is None: - return {"state": "projection_failed", "reason": "live_review_state_unknown"} + if getattr(bot.reviews_module.rebuild_pr_approval_state, "__name__", "") != "rebuild_pr_approval_state": + completion, write_approval = bot.reviews_module.rebuild_pr_approval_state( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if completion is None or write_approval is None: + return {"state": "projection_failed", "reason": "live_review_state_unknown"} + else: + approval_result = rebuild_pr_approval_state_result( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if not approval_result.get("ok"): + return {"state": "projection_failed", "reason": str(approval_result.get("reason"))} + completion = approval_result["completion"] + write_approval = approval_result["write_approval"] if not completion.get("completed"): return { "state": "awaiting_contributor_response", @@ -997,11 +1111,20 @@ def list_open_items_with_status_labels(bot) -> list[int]: page = 1 encoded_label = quote(label, safe="") while True: - result = bot.github_api("GET", f"issues?state=open&labels={encoded_label}&per_page=100&page={page}") - if result is None: - raise RuntimeError(f"Failed to list open items for status label '{label}'") + response = bot.github_api_request( + "GET", + f"issues?state=open&labels={encoded_label}&per_page=100&page={page}", + retry_policy="idempotent_read", + ) + if not response.ok: + raise RuntimeError( + f"Failed to list open items for status label '{label}': {response.failure_kind or 'unavailable'}" + ) + result = response.payload if not isinstance(result, list): - break + raise RuntimeError( + f"Failed to list open items for status label '{label}': invalid_payload" + ) for item in result: if isinstance(item, dict): number = item.get("number") diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index b65d74b26..a1f2f7fb3 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -279,6 +279,7 @@ def get_state_issue_snapshot(bot: StateStoreContext) -> StateIssueSnapshot | Non response = bot.github_api_request( "GET", f"issues/{state_issue_number}", + retry_policy="idempotent_read", suppress_error_log=True, ) if response.status_code != 200: @@ -307,10 +308,12 @@ def get_state_issue_snapshot(bot: StateStoreContext) -> StateIssueSnapshot | Non def conditional_patch_state_issue(bot: StateStoreContext, body: str, etag: str | None = None): state_issue_number = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + extra_headers = {"If-Match": etag} if isinstance(etag, str) and etag else None return bot.github_api_request( "PATCH", f"issues/{state_issue_number}", {"body": body}, + extra_headers=extra_headers, suppress_error_log=True, ) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index a04394927..c9d61ec5b 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -5,11 +5,15 @@ import io import json import os +import random +import time import zipfile from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import quote +import requests + from .reconcile import ( _artifact_expected_name, _artifact_expected_payload_name, @@ -41,6 +45,23 @@ def parse_timestamp(value: Any) -> datetime | None: return None +def _read_api_payload(bot, endpoint: str) -> tuple[Any | None, str | None]: + try: + response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read", suppress_error_log=True) + except SystemExit: + payload = bot.github_api("GET", endpoint) + return payload, None if payload is not None else "unavailable" + if not response.ok: + return None, response.failure_kind or "unavailable" + return response.payload, None + + +def _download_retry_delay(bot, retry_attempt: int) -> float: + base = float(getattr(bot, "LOCK_RETRY_BASE_SECONDS", 2.0)) + bounded_base = min(base * (2 ** max(retry_attempt - 1, 0)), 8.0) + return bounded_base + random.uniform(0, bounded_base) + + def observer_run_reason_from_details(run_details: dict, runbook_signature: dict | None) -> str: status = str(run_details.get("status", "")).strip() conclusion = run_details.get("conclusion") @@ -221,8 +242,8 @@ def _fetch_workflow_runs_for_file(bot, workflow_file: str, event_name: str) -> l page = 1 encoded_workflow = quote(workflow_file, safe="") while True: - response = bot.github_api( - "GET", + response, _ = _read_api_payload( + bot, f"actions/workflows/{encoded_workflow}/runs?event={quote(event_name, safe='')}&per_page=100&page={page}", ) if response is None: @@ -237,7 +258,7 @@ def _fetch_workflow_runs_for_file(bot, workflow_file: str, event_name: str) -> l def _fetch_run_detail(bot, run_id: int) -> dict | None: - response = bot.github_api("GET", f"actions/runs/{run_id}") + response, _ = _read_api_payload(bot, f"actions/runs/{run_id}") if isinstance(response, dict): return response return None @@ -247,7 +268,7 @@ def _list_run_artifacts(bot, run_id: int) -> list[dict] | None: artifacts: list[dict] = [] page = 1 while True: - response = bot.github_api("GET", f"actions/runs/{run_id}/artifacts?per_page=100&page={page}") + response, _ = _read_api_payload(bot, f"actions/runs/{run_id}/artifacts?per_page=100&page={page}") if response is None: return None page_artifacts = response.get("artifacts") if isinstance(response, dict) else None @@ -265,15 +286,32 @@ def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) download_url = artifact.get("archive_download_url") if not isinstance(download_url, str) or not download_url: return "missing_download_url", None - response = bot.requests.request( - "GET", - download_url, - headers={ - "Authorization": f"Bearer {bot.get_github_token()}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) + max_attempts = int(getattr(bot, "LOCK_API_RETRY_LIMIT", 5)) + 1 + response = None + for attempt in range(1, max_attempts + 1): + try: + response = bot.requests.request( + "GET", + download_url, + headers={ + "Authorization": f"Bearer {bot.get_github_token()}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + except requests.RequestException: + if attempt < max_attempts: + time.sleep(_download_retry_delay(bot, attempt)) + continue + return "download_unavailable", None + if response.status_code == 429 or response.status_code >= 500: + if attempt < max_attempts: + time.sleep(_download_retry_delay(bot, attempt)) + continue + return "download_unavailable", None + break + if response is None: + return "download_unavailable", None if response.status_code >= 400: return "download_failed", None try: @@ -387,6 +425,8 @@ def evaluate_deferred_gap_state( if isinstance(scan_outcomes, dict): if any(outcome == "expired" for outcome in scan_outcomes.values()): return "artifact_expired", "prior_visibility_or_retention_proof_required" + if any(outcome == "download_unavailable" for outcome in scan_outcomes.values()): + return "observer_state_unknown", "artifact_download_unavailable" invalid_outcomes = {"missing_download_url", "download_failed", "invalid_payload_layout", "invalid_payload_format"} if any(outcome in invalid_outcomes for outcome in scan_outcomes.values()): return "artifact_invalid", "artifact_download_or_payload_invalid" @@ -441,7 +481,7 @@ def _list_issue_comments_paginated(bot, issue_number: int) -> tuple[list[dict] | comments: list[dict] = [] page = 1 while True: - response = bot.github_api("GET", f"issues/{issue_number}/comments?per_page=100&page={page}") + response, _ = _read_api_payload(bot, f"issues/{issue_number}/comments?per_page=100&page={page}") if response is None: return None, False if not isinstance(response, list): @@ -470,7 +510,7 @@ def _is_automation_comment(comment: dict) -> bool: def _fetch_live_issue_comment(bot, comment_id: str) -> dict | None: if not comment_id.isdigit(): return None - response = bot.github_api("GET", f"issues/comments/{comment_id}") + response, _ = _read_api_payload(bot, f"issues/comments/{comment_id}") return response if isinstance(response, dict) else None @@ -616,7 +656,7 @@ def _discover_visible_review_events(bot, issue_number: int, review_data: dict) - def _discover_visible_review_comment_events(bot, issue_number: int, review_data: dict) -> tuple[list[dict] | None, bool]: watermark = _load_surface_watermark(review_data, "review_comments") watermark["last_scan_started_at"] = _now_iso() - comments = bot.github_api("GET", f"pulls/{issue_number}/comments?per_page=100") + comments, _ = _read_api_payload(bot, f"pulls/{issue_number}/comments?per_page=100") if comments is None: return None, False if not isinstance(comments, list): @@ -767,7 +807,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: if not isinstance(review_data, dict): continue issue_number = int(issue_key) - pull_request = bot.github_api("GET", f"pulls/{issue_number}") + pull_request, _ = _read_api_payload(bot, f"pulls/{issue_number}") if not isinstance(pull_request, dict) or str(pull_request.get("state", "")).lower() != "open": continue deferred_gaps = review_data.get("deferred_gaps") From 7791d4a32562d96c27dbbfcaf3b1f1c19c8bf11d Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 02:19:48 +0900 Subject: [PATCH 002/209] fix(reviewer-bot): harden persistence and projection contracts --- .github/reviewer-bot-tests/test_main.py | 1008 +++++- .../reviewer-bot-tests/test_reviewer_bot.py | 2816 ++++++++++++++--- scripts/reviewer_bot.py | 17 +- scripts/reviewer_bot_lib/app.py | 10 +- scripts/reviewer_bot_lib/commands.py | 3 +- scripts/reviewer_bot_lib/comment_routing.py | 19 +- scripts/reviewer_bot_lib/context.py | 4 + scripts/reviewer_bot_lib/github_api.py | 7 +- scripts/reviewer_bot_lib/lifecycle.py | 16 +- scripts/reviewer_bot_lib/maintenance.py | 26 +- scripts/reviewer_bot_lib/project_board.py | 6 +- scripts/reviewer_bot_lib/reconcile.py | 107 +- scripts/reviewer_bot_lib/reviews.py | 286 +- scripts/reviewer_bot_lib/sweeper.py | 6 +- 14 files changed, 3694 insertions(+), 637 deletions(-) diff --git a/.github/reviewer-bot-tests/test_main.py b/.github/reviewer-bot-tests/test_main.py index 65eb43232..96567c10e 100644 --- a/.github/reviewer-bot-tests/test_main.py +++ b/.github/reviewer-bot-tests/test_main.py @@ -1,3 +1,5 @@ +import json + import pytest from scripts import reviewer_bot @@ -7,6 +9,7 @@ ReviewerBotContext, StateStoreContext, ) +from scripts.reviewer_bot_lib.lifecycle import HeadObservationRepairResult def make_state(): @@ -501,6 +504,190 @@ def test_issue_close_then_close_comment_does_not_leave_active_review(monkeypatch assert "42" not in state["active_reviews"] +def test_main_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + + initial_state = make_state() + review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + reloaded_state = make_state() + load_calls = {"count": 0} + save_calls = [] + sync_calls = [] + + def fake_load_state(*, fail_on_unavailable=False): + load_calls["count"] += 1 + if load_calls["count"] == 1: + return initial_state + return reloaded_state + + 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", fake_load_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda state: save_calls.append("42" in state["active_reviews"]) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True, + ) + + reviewer_bot.main() + + assert save_calls == [False] + assert len(sync_calls) == 1 + assert sync_calls[0][0] is reloaded_state + assert sync_calls[0][1] == [42] + + +def test_main_closed_issue_comment_without_entry_skips_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + + state = make_state() + save_called = {"value": False} + sync_calls = [] + + 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, + "save_state", + lambda current: save_called.__setitem__("value", True) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False, + ) + + reviewer_bot.main() + + assert save_called["value"] is False + assert sync_calls == [[42]] + + +def test_main_label_signoff_issue_saves_before_status_projection(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") + + initial_state = make_state() + review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + reloaded_state = make_state() + load_calls = {"count": 0} + call_order = [] + + def fake_load_state(*, fail_on_unavailable=False): + load_calls["count"] += 1 + if load_calls["count"] == 1: + return initial_state + return reloaded_state + + 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", fake_load_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) + monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: call_order.append("save") or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: call_order.append("sync") or current is reloaded_state, + ) + + reviewer_bot.main() + + assert initial_state["active_reviews"]["42"]["review_completion_source"] == "issue_label: sign-off: create pr" + assert call_order == ["save", "sync"] + + +def test_main_label_signoff_issue_does_not_project_labels_when_save_fails(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + 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, "get_repo_labels", lambda: ["sign-off: create pr"]) + monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "save_state", lambda current: False) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: (_ for _ in ()).throw( + AssertionError("status projection must not run after failed save") + ), + ) + + with pytest.raises(SystemExit) as excinfo: + reviewer_bot.main() + + assert excinfo.value.code == 1 + + def test_main_fails_when_save_state_fails(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -557,11 +744,41 @@ def test_main_schedule_backfills_existing_transition_notice_without_duplicate_co 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "check_overdue_reviews", + lambda bot, current: [ + { + "issue_number": 42, + "reviewer": "alice", + "days_overdue": 20, + "days_since_warning": 15, + "needs_warning": False, + "needs_transition": True, + } + ], + ) monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}) - monkeypatch.setattr(reviewer_bot, "save_state", lambda current: True) + save_calls = [] + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append(current["active_reviews"]["42"]["transition_notice_sent_at"]) or True, + ) monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) @@ -574,6 +791,7 @@ def fake_api(method, endpoint, data=None): reviewer_bot.main() assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" assert posted == [] + assert save_calls == ["2026-03-25T15:22:42Z"] def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypatch): @@ -593,7 +811,14 @@ def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypa 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) monkeypatch.setattr( reviewer_bot, @@ -602,18 +827,30 @@ def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypa ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), ) - monkeypatch.setattr(reviewer_bot, "save_state", lambda current: True) + save_calls = [] + monkeypatch.setattr(reviewer_bot, "save_state", lambda current: save_calls.append(current) or True) monkeypatch.setattr( reviewer_bot, "sync_status_labels_for_items", @@ -624,6 +861,58 @@ def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypa assert review["reviewer_review"]["accepted"]["semantic_key"] == "pull_request_review:10" assert synced_issue_numbers == [42] + assert len(save_calls) == 1 + + +def test_main_schedule_structured_head_repair_result_without_mutation_skips_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_called = {"value": False} + + 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_called.__setitem__("value", True) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("no touched items expected")), + ) + + reviewer_bot.main() + + assert save_called["value"] is False def test_main_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): @@ -1012,3 +1301,692 @@ def track_save(_state): assert excinfo.value.code == 1 assert save_called["value"] is False + + +def test_main_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "bob" + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + __import__("json").dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 700, + "source_run_attempt": 1, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "700") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + 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.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/11": + return { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "pull_request_review:11" in current["active_reviews"]["42"]["deferred_gaps"], + } + ) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, + ) + + reviewer_bot.main() + + assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] + assert synced_issue_numbers == [42] + + +def test_main_pull_request_synchronize_head_only_mutation_still_saves_state(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "pull_request_target") + monkeypatch.setenv("EVENT_ACTION", "synchronize") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("PR_HEAD_SHA", "head-2") + monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_head_sha"] = "head-1" + review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] + + save_calls = [] + + 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.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append(current["active_reviews"]["42"]["active_head_sha"]) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_calls == ["head-2"] + + +def test_main_schedule_identical_live_read_failure_does_not_repeat_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_snapshots = [] + + 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + reviewer_bot.main() + + assert len(save_snapshots) == 1 + assert save_snapshots[0]["kind"] == "live_read_failure" + + +def test_main_identical_projection_failure_does_not_repeat_repair_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_markers = [] + + 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_comment_event", + lambda current: reviewer_bot.collect_touched_item(42) or False, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_markers.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed")), + ) + + reviewer_bot.main() + reviewer_bot.main() + + assert len(save_markers) == 1 + assert save_markers[0]["kind"] == "projection_failure" + + +def test_main_changed_projection_failure_reason_triggers_new_repair_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_markers = [] + failure_messages = iter(["projection failed", "projection still failed differently"]) + + 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_comment_event", + lambda current: reviewer_bot.collect_touched_item(42) or False, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_markers.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, + ) + + def fail_projection(current, issue_numbers): + raise RuntimeError(next(failure_messages)) + + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", fail_projection) + + reviewer_bot.main() + reviewer_bot.main() + + assert len(save_markers) == 2 + assert save_markers[0]["reason"] == "projection failed" + assert save_markers[1]["reason"] == "projection still failed differently" + + +def test_main_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 710, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /queue"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "710") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + + 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.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/210": + return { + "body": "@guidelines-bot /queue", + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "issue_comment:210" in current["active_reviews"]["42"]["deferred_gaps"], + } + ) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] + + +def test_main_workflow_run_deferred_comment_contract_drift_persists_gap_update( + tmp_path, monkeypatch +): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + payload_path = tmp_path / "deferred-command.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 712, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:201", + "pr_number": 42, + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /claim"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "712") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + + 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, [])) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "issues/comments/201": + return { + "body": "@guidelines-bot /claim", + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + dict(current["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]) + ) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_snapshots[0]["reason"] == "reconcile_failed_closed" + + +def test_main_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["pull_request_review_comment:310"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 711, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:310", + "pr_number": 42, + "comment_id": 310, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": reviewer_bot.comment_routing_module._digest_body("review comment body"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "711") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + + 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, [])) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/310": + return { + "body": "review comment body", + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + "created_at": "2026-03-17T10:00:00Z", + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "pull_request_review_comment:310" + in current["active_reviews"]["42"]["deferred_gaps"], + } + ) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_snapshots == [ + {"reconciled": ["pull_request_review_comment:310"], "gap_present": False} + ] + + +def test_main_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_calls = [] + + def fake_sweep(bot, current): + current["active_reviews"]["42"].setdefault("reconciled_source_events", []).append( + "pull_request_review:500" + ) + return True + + 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.maintenance_module, "sweep_deferred_gaps", fake_sweep) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append( + list(current["active_reviews"]["42"]["reconciled_source_events"]) + ) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_calls == [["pull_request_review:500"]] + + +def test_main_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + + save_calls = [] + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}}, + {}, + "ok", + True, + None, + 0, + None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + 200, + [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ) + raise AssertionError(endpoint) + + 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append( + { + "last_reviewer_activity": current["active_reviews"]["42"]["last_reviewer_activity"], + "transition_warning_sent": current["active_reviews"]["42"]["transition_warning_sent"], + "transition_notice_sent_at": current["active_reviews"]["42"]["transition_notice_sent_at"], + } + ) or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + reviewer_bot.main() + + assert save_calls == [ + { + "last_reviewer_activity": "2026-03-17T10:01:00Z", + "transition_warning_sent": None, + "transition_notice_sent_at": None, + } + ] diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index 8b80ef693..74481b68f 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -1,8 +1,11 @@ +import io import json import os import subprocess +import zipfile from datetime import timedelta from pathlib import Path +from typing import get_type_hints import pytest import yaml @@ -10,6 +13,7 @@ from builder import build_cli from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing, sweeper +from scripts.reviewer_bot_lib.context import ReviewerBotContext class FakeGitHubResponse: @@ -26,6 +30,13 @@ def json(self): return self._payload +def make_zip_payload(file_name: str, payload: dict) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(file_name, json.dumps(payload)) + return buffer.getvalue() + + def make_state(epoch: str = "freshness_v15"): return { "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, @@ -150,6 +161,341 @@ def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY +def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): + hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + + +def test_runtime_context_protocol_exposes_structured_head_repair_contract(): + hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + + +def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, **kwargs: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + + message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( + reviewer_bot, + state, + 42, + require_pull_request_context=True, + ) + + assert success is True + assert changed is False + assert "no reconciliation transitions applied" in message + + +def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T09:00:00Z", + actor="alice", + ) + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") + + changed = reviewer_bot.comment_routing_module._record_conversation_freshness( + reviewer_bot, + state, + 42, + "alice", + 100, + "2026-03-17T10:00:00Z", + ) + + assert changed is True + assert review["last_reviewer_activity"] == "2026-03-17T10:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + + +def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutation(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_head_sha"] = "head-1" + review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("PR_HEAD_SHA", "head-2") + monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") + + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + + assert reviewer_bot.handle_pull_request_target_synchronize(state) is True + assert review["active_head_sha"] == "head-2" + + +def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "bob" + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/11": + return { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "pull_request_review:11" in review["reconciled_source_events"] + assert "pull_request_review:11" not in review["deferred_gaps"] + + +def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:12", + "pr_number": 42, + "review_id": 12, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "pulls/42/reviews/12": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = review["deferred_gaps"]["pull_request_review:12"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" + + +def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /queue" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 610, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/210": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "issue_comment:210" in review["reconciled_source_events"] + assert "issue_comment:210" not in review["deferred_gaps"] + + +def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_reviewer_review_from_live_review", + lambda review_data, live_review, actor=None: False, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, actor=None: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + + changed = sweeper._repair_visible_review_gap( + reviewer_bot, + review, + 42, + "pull_request_review:303", + { + "id": 303, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + ) + + assert changed is True + assert "pull_request_review:303" in review["reconciled_source_events"] + assert "pull_request_review:303" not in review["deferred_gaps"] + + def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): observed = {} monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) @@ -200,8 +546,23 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): assert observed["extra_headers"] == {"If-Match": '"etag-1"'} -def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): - observed = [] +def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", None) + + assert observed["extra_headers"] is None + + +def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): + observed = [] def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed.append((endpoint, kwargs.get("retry_policy"))) @@ -260,6 +621,75 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): assert observed[1] == ("git/ref/heads/main", "idempotent_read") +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + reviewer_bot.GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"message": "missing tree"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="missing tree SHA"): + reviewer_bot.get_lock_ref_snapshot() + + +def test_save_state_retries_precondition_failed_conflict(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + snapshot = reviewer_bot.StateIssueSnapshot( + body="body", + etag='"etag"', + html_url="https://example.com/state/1", + ) + responses = iter( + [ + reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), + reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) + monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) + monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) + monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) + monkeypatch.setattr( + reviewer_bot, + "render_state_issue_body", + lambda state_obj, lock_meta, base_body: "updated", + ) + monkeypatch.setattr( + reviewer_bot, + "conditional_patch_state_issue", + lambda body, etag=None: next(responses), + ) + monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) + + assert reviewer_bot.save_state(state) is True + + def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): context = reviewer_bot.LeaseContext( lock_token="expected-token", @@ -390,6 +820,106 @@ def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): assert result.retry_attempts == 1 +def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.retry_attempts == 1 + + +def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "rate_limited" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + + +def test_github_graphql_request_reports_invalid_payload(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_graphql_request_reports_graphql_errors(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse( + 200, {"errors": [{"message": "boom"}]}, "boom" + ), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_api_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + observed = {} + + def fake_request(method, url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) + + assert result.ok is True + assert observed["timeout"] == 12.5 + + +def test_github_api_request_rejects_idempotent_retry_for_non_get(): + with pytest.raises(ValueError, match="only valid for REST GET"): + reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") + + +def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): + with pytest.raises(ValueError, match="only valid for GraphQL queries"): + reviewer_bot.github_graphql_request( + "mutation { closeIssue(input: {}) { clientMutationId } }", + retry_policy="idempotent_read", + ) + + @pytest.mark.parametrize( ("env", "expected"), [ @@ -523,10 +1053,26 @@ def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): monkeypatch.setenv("COMMENT_ID", "100") monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - assert reviewer_bot.handle_comment_event(state) is False + assert reviewer_bot.handle_comment_event(state) is True assert "42" not in state["active_reviews"] +def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): + state = make_state() + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + + assert reviewer_bot.handle_comment_event(state) is False + assert state["active_reviews"] == {} + + def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -546,7 +1092,7 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): assert accepted["semantic_key"] == "issue_comment:100" -def test_label_signoff_create_pr_marks_issue_review_complete_and_syncs_status(monkeypatch): +def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -561,11 +1107,12 @@ def test_label_signoff_create_pr_marks_issue_review_complete_and_syncs_status(mo monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - synced = [] monkeypatch.setattr( reviewer_bot, "sync_status_labels_for_items", - lambda state_obj, issue_numbers: synced.append(list(issue_numbers)) or True, + lambda *args, **kwargs: pytest.fail( + "status sync should run only from app orchestration after save" + ), ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) posted = [] @@ -573,7 +1120,6 @@ def test_label_signoff_create_pr_marks_issue_review_complete_and_syncs_status(mo assert reviewer_bot.handle_comment_event(state) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" assert review["current_cycle_completion"]["completed"] is True - assert synced == [[42]] assert posted == [(42, "✅ Added label `sign-off: create pr`")] @@ -721,6 +1267,11 @@ def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypat review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" review["transition_warning_sent"] = "2026-03-10T00:00:00Z" monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) monkeypatch.setattr( reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", @@ -729,6 +1280,20 @@ def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypat outcome="unchanged", ), ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "check_overdue_reviews", + lambda bot, state: [ + { + "issue_number": 42, + "reviewer": "alice", + "days_overdue": 20, + "days_since_warning": 15, + "needs_warning": False, + "needs_transition": True, + } + ], + ) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) posted = [] @@ -771,16 +1336,27 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), ) assert reviewer_bot.handle_scheduled_check(state) is True accepted = review["reviewer_review"]["accepted"] @@ -820,6 +1396,74 @@ def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation assert review_data["active_head_sha"] == "head-1" +def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "skipped_not_found" + assert result.failure_kind == "not_found" + + +def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "invalid_live_payload" + assert result.reason == "pull_request_head_unavailable" + + +def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "closed", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "skipped_not_open" + + def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): review_data = { "active_head_sha": "head-1", @@ -905,6 +1549,69 @@ def fake_head_repair(bot, issue_number, review_data): assert review_43["repair_needed"] is None +def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) + monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) + + first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + + assert first is True + assert second is False + assert review["repair_needed"]["recorded_at"] == "2026-03-01T00:00:00Z" + + +def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "pull_request_unavailable", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert review["repair_needed"] is None + + def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -1220,6 +1927,7 @@ def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch) "github_api", lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} assert metadata["reason"] == "review_head_stale" @@ -1457,18 +2165,20 @@ def test_project_status_labels_prefers_current_head_review_over_newer_stale_revi assert metadata["reason"] == "completion_missing" -def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): +def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None - review["current_reviewer"] = "vccjgust" - review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" reviewer_bot.reviews_module.accept_channel_event( review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-02-18T09:30:00Z", - actor="dana", + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, ) monkeypatch.setattr( reviewer_bot, @@ -1478,34 +2188,43 @@ def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response monkeypatch.setattr( reviewer_bot, "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, ) monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", lambda issue_number: [ { - "id": 30, + "id": 10, "state": "COMMENTED", - "submitted_at": "2026-02-18T10:00:00Z", - "commit_id": "head-older", - "user": {"login": "vccjgust"}, + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, }, { - "id": 31, + "id": 99, "state": "COMMENTED", - "submitted_at": "2026-02-18T11:00:00Z", - "commit_id": "head-current", - "user": {"login": "vccjgust"}, + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, }, ], ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + response_state = reviewer_bot.compute_reviewer_response_state(42, review) -def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): + assert response_state["state"] == "awaiting_contributor_response" + assert response_state["reason"] == "completion_missing" + assert response_state["reviewer_review"]["semantic_key"] == "pull_request_review:10" + assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" + + +def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -1513,10 +2232,12 @@ def test_project_status_labels_prefers_newer_contributor_comment_over_live_revie review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" reviewer_bot.reviews_module.accept_channel_event( review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-03-17T10:05:00Z", - actor="bob", + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, ) monkeypatch.setattr( reviewer_bot, @@ -1538,46 +2259,37 @@ def test_project_status_labels_prefers_newer_contributor_comment_over_live_revie "submitted_at": "2026-03-17T10:01:00Z", "commit_id": "head-1", "user": {"login": "alice"}, - } + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, ], ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "contributor_comment_newer" + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) -def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["last_reviewer_activity"] = "2026-03-20T10:00:00Z" - review["transition_warning_sent"] = "2026-03-21T10:00:00Z" - review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" - reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") - assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" -def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): +def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" reviewer_bot.reviews_module.accept_channel_event( review, "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", + semantic_key="pull_request_review:99", + timestamp="2026-03-02T00:00:00Z", actor="alice", - reviewed_head_sha="head-1", + reviewed_head_sha="head-0", source_precedence=1, ) monkeypatch.setattr( @@ -1585,56 +2297,63 @@ def test_project_status_labels_emits_awaiting_write_approval_only_after_completi "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-20T00:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-21T00:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "bob"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} - assert metadata["state"] == "awaiting_write_approval" - review["mandatory_approver_required"] = True - desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] -def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): +def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_revision", + semantic_key="pull_request_sync:42:head-1", + timestamp="2026-03-17T12:00:00Z", + reviewed_head_sha="head-1", + source_precedence=1, + ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", @@ -1642,38 +2361,43 @@ def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkey ) monkeypatch.setattr( reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], ) response_state = reviewer_bot.compute_reviewer_response_state(42, review) - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "reviews_unavailable" + assert response_state["state"] == "awaiting_reviewer_response" + assert response_state["reason"] == "contributor_revision_newer" -def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): +def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-17T09:00:00Z" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" reviewer_bot.reviews_module.accept_channel_event( review, "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", actor="alice", - reviewed_head_sha="head-1", + reviewed_head_sha="head-0", source_precedence=1, ) monkeypatch.setattr( @@ -1681,134 +2405,115 @@ def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypa "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + desired_labels, _ = reviewer_bot.project_status_labels_for_item(42, state) + preview = reviewer_bot.preview_board_projection_for_item(state, 42) - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "permission_unavailable" + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert preview.desired is not None + assert preview.desired.review_state == reviewer_bot.REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR -def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): +def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-20T12:34:56Z" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + review["repair_needed"] = { + "kind": "projection_failure", + "reason": "projection_failed", + } monkeypatch.setattr( reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, ) - with pytest.raises(RuntimeError, match="server_error"): - reviewer_bot.list_open_items_with_status_labels() + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.desired is not None + assert ( + preview.desired.needs_attention + == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED + ) -def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): +def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - payload_path = tmp_path / "deferred.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "APPROVED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", + review["current_reviewer"] = "vccjgust" + review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-02-18T09:30:00Z", + actor="dana", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") monkeypatch.setattr( reviewer_bot, "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), + lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, ) monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", lambda issue_number: [ { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } + "id": 30, + "state": "COMMENTED", + "submitted_at": "2026-02-18T10:00:00Z", + "commit_id": "head-older", + "user": {"login": "vccjgust"}, + }, + { + "id": 31, + "state": "COMMENTED", + "submitted_at": "2026-02-18T11:00:00Z", + "commit_id": "head-current", + "user": {"login": "vccjgust"}, + }, ], ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" -def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): +def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -1816,12 +2521,15 @@ def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_hea review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" reviewer_bot.reviews_module.accept_channel_event( review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-03-17T10:05:00Z", + actor="bob", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) monkeypatch.setattr( reviewer_bot, @@ -1835,118 +2543,256 @@ def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_hea { "id": 10, "state": "COMMENTED", - "submitted_at": "2026-03-17T10:00:00Z", + "submitted_at": "2026-03-17T10:01:00Z", "commit_id": "head-1", "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, + } ], ) - assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" - assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "contributor_comment_newer" -def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review(tmp_path, monkeypatch): +def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill(): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["last_reviewer_activity"] = "2026-03-20T10:00:00Z" + review["transition_warning_sent"] = "2026-03-21T10:00:00Z" + review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" + reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") + assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + + +def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) reviewer_bot.reviews_module.accept_channel_event( review, "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", actor="alice", - reviewed_head_sha="head-0", + reviewed_head_sha="head-1", source_precedence=1, ) - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:99", - "pr_number": 42, - "review_id": 99, - "source_submitted_at": "2026-03-17T11:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-0", - "actor_login": "alice", - } - ), - encoding="utf-8", + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - def fake_github_api(method, endpoint, data=None): + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/99": - return { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "bob"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert metadata["state"] == "awaiting_write_approval" + review["mandatory_approver_required"] = True + desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + + +def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), ) - assert reviewer_bot.handle_workflow_run_event(state) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" + response_state = reviewer_bot.compute_reviewer_response_state(42, review) -def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_unavailable" + + +def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid( + monkeypatch, +): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - payload_path = tmp_path / "deferred-review.json" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {}}, + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_head_unavailable" + + +def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "permission_unavailable" + + +def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="server_error"): + reviewer_bot.list_open_items_with_status_labels() + + +def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + payload_path = tmp_path / "deferred.json" payload_path.write_text( json.dumps( { @@ -1961,7 +2807,7 @@ def test_workflow_run_review_submission_clears_warning_and_transition_notice_mar "pr_number": 42, "review_id": 11, "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", + "source_review_state": "APPROVED", "source_commit_id": "head-1", "actor_login": "alice", } @@ -1977,11 +2823,11 @@ def test_workflow_run_review_submission_clears_warning_and_transition_notice_mar reviewer_bot, "github_api", lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, + "pulls/42": {"head": {"sha": "head-2"}}, "pulls/42/reviews/11": { "id": 11, "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", + "state": "APPROVED", "commit_id": "head-1", "user": {"login": "alice"}, }, @@ -1994,212 +2840,556 @@ def test_workflow_run_review_submission_clears_warning_and_transition_notice_mar { "id": 11, "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", + "state": "APPROVED", "commit_id": "head-1", "user": {"login": "alice"}, } ], ) assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None + assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False -def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): +def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:99", - "pr_number": 42, - "comment_id": 99, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - } - ), - encoding="utf-8", + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") monkeypatch.setattr( reviewer_bot, "github_api", - lambda method, endpoint, data=None: ( - {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None - ), + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" - - -def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - live_body = "author reply in review thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 701, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:301", - "pr_number": 42, - "comment_id": 301, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - "actor_id": 5, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", - } + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" + assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] + + +def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change( + monkeypatch, +): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/301": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") + changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( + reviewer_bot, + 42, + review, + ) - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["contributor_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:301" + assert changed is True + assert preferred_review is not None + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None -def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): +def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - live_body = "reviewer reply in thread" - payload_path = tmp_path / "deferred-review-comment.json" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_reviewer_response" + assert review == before + + +def test_compute_pr_approval_state_result_is_pure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + + result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) + + assert result["ok"] is True + assert result["completion"]["completed"] is True + assert review == before + + +def test_apply_pr_approval_state_mutates_expected_fields(): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + + reviewer_bot.reviews_module.apply_pr_approval_state( + review, + completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, + write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, + current_head_sha="head-1", + ) + + assert review["active_head_sha"] == "head-1" + assert review["current_cycle_completion"]["completed"] is True + assert review["current_cycle_write_approval"]["has_write_approval"] is True + assert review["review_completion_source"] == "live_review_rebuild" + + +def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + payload_path = tmp_path / "deferred-review.json" payload_path.write_text( json.dumps( { "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 702, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:302", + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:99", "pr_number": 42, - "comment_id": 302, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T11:00:00Z", + "review_id": 99, + "source_submitted_at": "2026-03-17T11:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-0", "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", } ), encoding="utf-8", ) monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") def fake_github_api(method, endpoint, data=None): if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/302": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/99": return { - "body": live_body, - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, } raise AssertionError(f"Unexpected endpoint: {endpoint}") monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" -def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): +def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + payload_path = tmp_path / "deferred-review.json" payload_path.write_text( json.dumps( { "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 703, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:303", + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", "pr_number": 42, - "comment_id": 303, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", } ), encoding="utf-8", ) monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, + "pulls/42/reviews/11": { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + + +def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:99", + "pr_number": 42, + "comment_id": 99, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: ( + {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None + ), + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" + + +def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "author reply in review thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 701, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:301", + "pr_number": 42, + "comment_id": 301, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + "actor_id": 5, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/301": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["contributor_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:301" + + +def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "reviewer reply in thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 702, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:302", + "pr_number": 42, + "comment_id": 302, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T11:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/302": + return { + "body": live_body, + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" + + +def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 703, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:303", + "pr_number": 42, + "comment_id": 303, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") monkeypatch.setattr( @@ -2462,12 +3652,55 @@ def fake_github_api(method, endpoint, data=None): lambda *args, **kwargs: command_calls.append("called") or True, ) - assert reviewer_bot.handle_workflow_run_event(state) is False + assert reviewer_bot.handle_workflow_run_event(state) is True assert command_calls == [] assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] +def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + payload = { + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_event_key": "issue_comment:201", + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_created_at": "2026-03-17T10:00:00Z", + "pr_number": 42, + "source_run_id": 603, + "source_run_attempt": 1, + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", + } + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + + result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( + reviewer_bot, + review, + payload, + "@guidelines-bot /claim", + ) + + assert result.live_classified is None + assert result.changed is True + assert result.failed_closed is True + assert review["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" + + def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -2740,6 +3973,46 @@ def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkey assert called["post"] == 0 +def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"not": "a list"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + + +def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + + def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") @@ -3447,6 +4720,625 @@ def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency( assert "concurrency" not in job +def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_api_request("GET", "issues/42") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "server_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + + +def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + + def always_fail(*args, **kwargs): + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "transport_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert "timeout" in str(result.transport_error) + + +def test_github_graphql_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + observed = {} + + def fake_post(url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) + + result = reviewer_bot.github_graphql_request( + "query { viewer { login } }", timeout_seconds=9.5 + ) + + assert result.ok is True + assert observed["timeout"] == 9.5 + + +def test_maybe_record_head_observation_repair_uses_github_api_fallback_after_system_exit(monkeypatch): + review_data = { + "active_head_sha": "head-0", + "contributor_revision": {"accepted": None}, + } + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_channel_event", + lambda review_data, channel, **kwargs: True, + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.changed is True + assert result.outcome == "changed" + assert review_data["active_head_sha"] == "head-1" + + +def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): + monkeypatch.delenv("REPO_OWNER", raising=False) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) + + +def test_find_open_pr_for_branch_status_reports_found(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + [{"number": 42, "html_url": "https://example.com/pr/42"}], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") + + assert status == "found" + assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} + + +def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, [], {}, "ok", True, None, 0, None + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + + +def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + + assert success is False + assert "Unable to verify triage permissions right now" in message + + +def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + +def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_pass_until_command( + state, + 42, + "alice", + "2099-01-01", + None, + ) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + +def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_claim_command(state, 42, "alice") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + +def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response, success = reviewer_bot.handle_release_command(state, 42, "alice", ["@bob"]) + + assert success is False + assert "Unable to verify triage permissions right now" in response + + +def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_release_command(state, 42, "alice") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + +def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + + +def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + + assert success is False + assert changed is False + assert "Unable to verify triage permissions right now" in message + + +def test_handle_rectify_command_reports_permission_denied(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + + message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + + assert success is False + assert changed is False + assert "Only maintainers with triage+ permission" in message + + +def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + ok, metadata = comment_routing._validate_accept_no_fls_changes_handoff( + reviewer_bot, + 42, + "alice", + ) + + assert ok is False + assert metadata["reason"] == "authorization_unavailable" + + +def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"] = { + "issue_comment:100": { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + assert reviewer_bot.handle_manual_dispatch(state) is True + pending = review["pending_privileged_commands"]["issue_comment:100"] + assert pending["status"] == "failed_closed" + assert pending["result"] == "live_permission_unavailable" + + +def test_get_pull_request_reviews_result_paginates(monkeypatch): + responses = { + "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult(200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None), + "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult(200, [{"id": 100}], {}, "ok", True, None, 0, None), + } + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result["ok"] is True + assert len(result["reviews"]) == 101 + + +def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": True, "reviews": [{"id": 10}]} + + +def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, {"not": "a list"}, {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} + + +def test_pull_request_read_result_reports_not_found(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} + + +def test_pull_request_read_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} + + +def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read lock commit"): + reviewer_bot.get_lock_ref_snapshot() + + +def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): + payload = {"source_event_key": "issue_comment:100"} + responses = iter( + [ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ] + ) + success_response = FakeGitHubResponse(200, None, "") + success_response.content = make_zip_payload("deferred-comment.json", payload) + + def fake_request(*args, **kwargs): + response = next(responses, success_response) + return response + + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(sweeper.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, artifact_payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "ok" + assert artifact_payload == payload + + +def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: (_ for _ in ()).throw( + sweeper.requests.RequestException("timeout") + ), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + + +def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + + +def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): + monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) + + assert sweeper._list_run_artifacts(reviewer_bot, 42) is None + + +@pytest.mark.parametrize( + ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), + [ + ( + {"source_event_name": "issue_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Comment Observer", + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-1-attempt-2", + "deferred-comment.json", + ), + ( + {"source_event_name": "pull_request_review", "source_event_action": "submitted", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Submitted Observer", + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-1-attempt-2", + "deferred-review-submitted.json", + ), + ( + {"source_event_name": "pull_request_review", "source_event_action": "dismissed", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Dismissed Observer", + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-1-attempt-2", + "deferred-review-dismissed.json", + ), + ( + {"source_event_name": "pull_request_review_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Comment Observer", + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-1-attempt-2", + "deferred-review-comment.json", + ), + ], +) +def test_deferred_workflow_identity_helpers_match_expected_contract( + payload, + workflow_name, + workflow_file, + artifact_name, + payload_name, +): + assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( + workflow_name, + workflow_file, + ) + assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name + assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name + + +@pytest.mark.parametrize( + ("workflow_path", "artifact_name", "payload_name"), + [ + ( + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-comment.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-submitted.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-dismissed.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-comment.json", + ), + ], +) +def test_observer_workflow_files_match_expected_artifact_contract( + workflow_path, artifact_name, payload_name +): + workflow_text = Path(workflow_path).read_text(encoding="utf-8") + + assert artifact_name in workflow_text + assert payload_name in workflow_text + + +def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + + +def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="run_attempt mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + + +def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="did not conclude successfully"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + + +def test_reviewer_board_manifest_includes_projection_repair_attention_option(): + options = reviewer_bot.REVIEWER_BOARD_PROJECT_MANIFEST[ + reviewer_bot.REVIEWER_BOARD_FIELD_NEEDS_ATTENTION + ]["options"] + + assert reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options + + def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 1043cfdfb..12425d2e2 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -810,11 +810,18 @@ def handle_rectify_command(state: dict, issue_number: int, comment_author: str) and current_reviewer.lower() == comment_author.lower() ) - has_triage = False + triage_status = "denied" if not is_current_reviewer: - has_triage = check_user_permission(comment_author, "triage") + triage_status = get_user_permission_status(comment_author, "triage") - if not is_current_reviewer and not has_triage: + if not is_current_reviewer and triage_status == "unavailable": + return ( + "❌ Unable to verify triage permissions right now; refusing to continue.", + False, + False, + ) + + if not is_current_reviewer and triage_status != "granted": if current_reviewer: return ( f"❌ Only the assigned reviewer (@{current_reviewer}) or a maintainer with triage+ " @@ -856,7 +863,9 @@ def handle_pull_request_target_synchronize(state: dict) -> bool: return lifecycle_module.handle_pull_request_target_synchronize(_runtime_bot(), state) -def maybe_record_head_observation_repair(issue_number: int, review_data: dict) -> bool: +def maybe_record_head_observation_repair( + issue_number: int, review_data: dict +) -> lifecycle_module.HeadObservationRepairResult: return lifecycle_module.maybe_record_head_observation_repair(_runtime_bot(), issue_number, review_data) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index d93331d4b..412647f1a 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -30,11 +30,19 @@ def _mark_projection_repair_needed(bot: ReviewerBotContext, state: dict, issue_n review_data = active_reviews.get(str(issue_number)) if not isinstance(review_data, dict): continue - review_data["repair_needed"] = { + marker = { "kind": "projection_failure", "reason": reason, "recorded_at": bot.datetime.now(bot.timezone.utc).isoformat(), } + existing = review_data.get("repair_needed") + if isinstance(existing, dict) and { + key: value for key, value in existing.items() if key != "recorded_at" + } == { + key: value for key, value in marker.items() if key != "recorded_at" + }: + continue + review_data["repair_needed"] = marker changed = True return changed diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 53b7233be..f9e5c8e20 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -267,8 +267,7 @@ def handle_label_command(bot, state: dict, issue_number: int, label_string: str) completion_changed = bot.mark_review_complete( state, issue_number, reviewer, "issue_label: sign-off: create pr" ) - status_changed = bot.sync_status_labels_for_items(state, [issue_number]) - state_changed = completion_changed or status_changed or state_changed + state_changed = completion_changed or state_changed else: results.append(f"❌ Failed to add label `{label}`") all_success = False diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 505593581..f1f36e87b 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -235,8 +235,16 @@ def _record_conversation_freshness(bot, state: dict, issue_number: int, comment_ timestamp=created_at, actor=comment_author, ) + previous_activity = review_data.get("last_reviewer_activity") + previous_warning = review_data.get("transition_warning_sent") + previous_notice = review_data.get("transition_notice_sent_at") bot.reviews_module.record_reviewer_activity(review_data, created_at) - return changed + activity_changed = ( + previous_activity != review_data.get("last_reviewer_activity") + or previous_warning != review_data.get("transition_warning_sent") + or previous_notice != review_data.get("transition_notice_sent_at") + ) + return changed or activity_changed return False @@ -260,7 +268,10 @@ def _validate_accept_no_fls_changes_handoff(bot, issue_number: int, comment_auth labels = bot.parse_issue_labels() if bot.FLS_AUDIT_LABEL not in labels: return False, {"reason": "missing_fls_audit_label"} - if not bot.check_user_permission(comment_author, "triage"): + permission_status = bot.get_user_permission_status(comment_author, "triage") + if permission_status == "unavailable": + return False, {"reason": "authorization_unavailable"} + if permission_status != "granted": return False, {"reason": "authorization_failed"} return True, { "command_name": "accept-no-fls-changes", @@ -383,9 +394,9 @@ def handle_comment_event(bot, state: dict) -> bool: return False if route == "issue_direct": if _issue_state() == "closed": - state.get("active_reviews", {}).pop(str(issue_number), None) + removed = state.get("active_reviews", {}).pop(str(issue_number), None) print(f"Ignoring direct comment on closed issue #{issue_number}") - return False + return removed is not None return _process_comment_event(bot, state, issue_number) if route == "pr_trusted_direct": if not _require_v18_for_pr(state, "pr_trusted_direct_comment"): diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 4812f9011..76936b030 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -7,6 +7,7 @@ from typing import Any, Protocol, runtime_checkable from .config import AssignmentAttempt, GitHubApiResult, LeaseContext, StateIssueSnapshot +from .lifecycle import HeadObservationRepairResult @runtime_checkable @@ -178,6 +179,9 @@ def handle_comment_event(self, state: dict) -> bool: ... def handle_manual_dispatch(self, state: dict) -> bool: ... def handle_scheduled_check(self, state: dict) -> bool: ... def handle_workflow_run_event(self, state: dict) -> bool: ... + def maybe_record_head_observation_repair( + self, issue_number: int, review_data: dict + ) -> HeadObservationRepairResult: ... def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int]) -> bool: ... def compute_reviewer_response_state( self, diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 3e8816be8..98c7172c3 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -160,13 +160,14 @@ def github_api_request( ) payload = None + invalid_payload = False if response.content: try: payload = response.json() except ValueError: - payload = None + invalid_payload = True - ok = response.status_code < 400 + ok = response.status_code < 400 and not invalid_payload normalized_headers = {key.lower(): value for key, value in response.headers.items()} if ok: return _build_result( @@ -180,7 +181,7 @@ def github_api_request( retry_attempts=retry_attempts, ) - failure_kind = _classify_failure(response.status_code) + failure_kind = _classify_failure(response.status_code, invalid_payload=invalid_payload) if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and _should_retry_status(response.status_code) and attempt < max_attempts: retry_attempts += 1 time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index ceaf4b946..8168d7387 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -5,6 +5,7 @@ import hashlib import json import os +from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timezone @@ -191,6 +192,12 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: if not head_sha: raise RuntimeError("Missing PR_HEAD_SHA for synchronize event") bot.collect_touched_item(issue_number) + previous_head_sha = review_data.get("active_head_sha") + previous_completion = deepcopy(review_data.get("current_cycle_completion")) + previous_write_approval = deepcopy(review_data.get("current_cycle_write_approval")) + previous_review_completed_at = review_data.get("review_completed_at") + previous_review_completed_by = review_data.get("review_completed_by") + previous_review_completion_source = review_data.get("review_completion_source") review_data["active_head_sha"] = head_sha timestamp = os.environ.get("EVENT_CREATED_AT", "") or _now_iso() changed = bot.reviews_module.accept_channel_event( @@ -202,7 +209,14 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: source_precedence=1, ) bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data) - return changed + approval_changed = ( + previous_completion != review_data.get("current_cycle_completion") + or previous_write_approval != review_data.get("current_cycle_write_approval") + or previous_review_completed_at != review_data.get("review_completed_at") + or previous_review_completed_by != review_data.get("review_completed_by") + or previous_review_completion_source != review_data.get("review_completion_source") + ) + return changed or previous_head_sha != review_data.get("active_head_sha") or approval_changed def maybe_record_head_observation_repair(bot, issue_number: int, review_data: dict) -> HeadObservationRepairResult: diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 1c58c97e7..e80f2a11b 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -37,6 +37,16 @@ def _clear_maintenance_repair_marker(review_data: dict, phase: str) -> bool: return True +def _repair_marker_matches(existing: dict | None, candidate: dict) -> bool: + if not isinstance(existing, dict): + return False + return { + key: value for key, value in existing.items() if key != "recorded_at" + } == { + key: value for key, value in candidate.items() if key != "recorded_at" + } + + def _record_maintenance_repair_marker( bot, review_data: dict, @@ -52,7 +62,8 @@ def _record_maintenance_repair_marker( "failure_kind": failure_kind, "recorded_at": _now_iso(bot), } - if review_data.get("repair_needed") == marker: + existing_marker = review_data.get("repair_needed") + if _repair_marker_matches(existing_marker, marker): return False review_data["repair_needed"] = marker return True @@ -158,7 +169,18 @@ def handle_manual_dispatch(bot, state: dict) -> bool: name = label.get("name") if isinstance(name, str): labels.add(name) - if bot.FLS_AUDIT_LABEL not in labels or not bot.check_user_permission(actor, "triage"): + permission_status = bot.get_user_permission_status(actor, "triage") + if bot.FLS_AUDIT_LABEL not in labels: + record["status"] = "failed_closed" + record["completed_at"] = _now_iso(bot) + record["result"] = "live_revalidation_failed" + return True + if permission_status == "unavailable": + record["status"] = "failed_closed" + record["completed_at"] = _now_iso(bot) + record["result"] = "live_permission_unavailable" + return True + if permission_status != "granted": record["status"] = "failed_closed" record["completed_at"] = _now_iso(bot) record["result"] = "live_revalidation_failed" diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index 3b21aafd2..d25a7dd79 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -15,6 +15,7 @@ REVIEWER_BOARD_FIELD_REVIEWER, REVIEWER_BOARD_FIELD_WAITING_SINCE, REVIEWER_BOARD_OPTION_ATTENTION_NO, + REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED, REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT, REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED, REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT, @@ -339,7 +340,10 @@ def derive_board_projection(input: BoardProjectionInput) -> BoardProjectionValue raise RuntimeError(f"Unsupported board review state for #{input.issue_number}: {derivation.state}") needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_NO - if review_data.get("mandatory_approver_required"): + repair_needed = review_data.get("repair_needed") + if isinstance(repair_needed, dict) and repair_needed.get("kind") == "projection_failure": + needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED + elif review_data.get("mandatory_approver_required"): needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED elif review_data.get("transition_notice_sent_at"): needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 98e199d0e..2c43725b6 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -4,6 +4,8 @@ import json import os +from copy import deepcopy +from dataclasses import dataclass from .comment_routing import ( _digest_body, @@ -27,6 +29,13 @@ def __init__(self, message: str, *, failure_kind: str | None = None): self.failure_kind = failure_kind +@dataclass(frozen=True) +class LiveCommentReplayValidationResult: + live_classified: dict | None + changed: bool + failed_closed: bool + + def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: try: response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") @@ -53,16 +62,21 @@ def _ensure_source_event_key(review_data: dict, source_event_key: str, payload: review_data["deferred_gaps"][source_event_key] = payload -def _clear_source_event_key(review_data: dict, source_event_key: str) -> None: +def _clear_source_event_key(review_data: dict, source_event_key: str) -> bool: deferred_gaps = review_data.get("deferred_gaps") if isinstance(deferred_gaps, dict): - deferred_gaps.pop(source_event_key, None) + if source_event_key in deferred_gaps: + deferred_gaps.pop(source_event_key, None) + return True + return False -def _mark_reconciled_source_event(review_data: dict, source_event_key: str) -> None: +def _mark_reconciled_source_event(review_data: dict, source_event_key: str) -> bool: reconciled = review_data.setdefault("reconciled_source_events", []) if source_event_key not in reconciled: reconciled.append(source_event_key) + return True + return False def _was_reconciled_source_event(review_data: dict, source_event_key: str) -> bool: @@ -75,6 +89,15 @@ def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dic reviews = bot.get_pull_request_reviews(issue_number) if reviews is None: raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") + before = { + "reviewer_review": deepcopy(review_data.get("reviewer_review")), + "active_head_sha": review_data.get("active_head_sha"), + "current_cycle_completion": deepcopy(review_data.get("current_cycle_completion")), + "current_cycle_write_approval": deepcopy(review_data.get("current_cycle_write_approval")), + "review_completed_at": review_data.get("review_completed_at"), + "review_completed_by": review_data.get("review_completed_by"), + "review_completion_source": review_data.get("review_completion_source"), + } refresh_reviewer_review_from_live_preferred_review( bot, issue_number, @@ -96,7 +119,16 @@ def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dic failure_kind=str(approval_result.get("failure_kind") or "unavailable"), ) completion = approval_result["completion"] - return bool(completion.get("completed")) + after = { + "reviewer_review": deepcopy(review_data.get("reviewer_review")), + "active_head_sha": review_data.get("active_head_sha"), + "current_cycle_completion": deepcopy(review_data.get("current_cycle_completion")), + "current_cycle_write_approval": deepcopy(review_data.get("current_cycle_write_approval")), + "review_completed_at": review_data.get("review_completed_at"), + "review_completed_by": review_data.get("review_completed_by"), + "review_completion_source": review_data.get("review_completion_source"), + } + return before != after or bool(completion.get("completed")) def reconcile_active_review_entry( @@ -117,7 +149,8 @@ def reconcile_active_review_entry( return f"ℹ️ #{issue_number} is not a pull request in this event context; `/rectify` only reconciles PR reviews.", True, False if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15" and os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": return "ℹ️ PR review freshness rectify is epoch-gated and currently inactive.", True, False - state_changed = bot.maybe_record_head_observation_repair(issue_number, review_data) + head_repair_result = bot.maybe_record_head_observation_repair(issue_number, review_data) + state_changed = head_repair_result.changed reviews = bot.get_pull_request_reviews(issue_number) if reviews is None: return f"❌ Failed to fetch reviews for PR #{issue_number}; cannot run `/rectify`.", False, False @@ -440,7 +473,7 @@ def _reconcile_deferred_comment( f"Deferred comment {comment_id} could not be validated from live GitHub data " f"({exc.failure_kind or 'unavailable'}); replay suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." ) - _update_deferred_gap( + gap_changed = _update_deferred_gap( bot, review_data, payload, @@ -448,7 +481,7 @@ def _reconcile_deferred_comment( summary, failure_kind=exc.failure_kind, ) - return changed + return changed or gap_changed _hydrate_reconcile_comment_context(live_comment, payload) live_body = live_comment.get("body") if not isinstance(live_body, str): @@ -457,19 +490,25 @@ def _reconcile_deferred_comment( changed = False if source_freshness_eligible: changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) - _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} body digest changed; command execution suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") - return changed + gap_changed = _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} body digest changed; command execution suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") + return changed or gap_changed changed = False if source_freshness_eligible: changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) or changed - live_classified = _validate_live_comment_replay_contract(bot, review_data, payload, live_body) - if live_classified is None: - return changed + validation_result = _validate_live_comment_replay_contract( + bot, + review_data, + payload, + live_body, + ) + if validation_result.live_classified is None: + return changed or validation_result.changed + live_classified = validation_result.live_classified if classified in {"command_only", "command_plus_text"}: changed = _handle_command(bot, state, pr_number, comment_author, live_classified) or changed - _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) - _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) - return changed + reconciled_changed = _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) + gap_cleared_changed = _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) + return changed or reconciled_changed or gap_cleared_changed def _update_deferred_gap( @@ -480,14 +519,15 @@ def _update_deferred_gap( diagnostic_summary: str, *, failure_kind: str | None = None, -) -> None: +) -> bool: source_event_key = str(payload.get("source_event_key", "")) if not source_event_key: - return + return False review_data.setdefault("deferred_gaps", {}) existing = review_data["deferred_gaps"].get(source_event_key, {}) if not isinstance(existing, dict): existing = {} + previous = deepcopy(existing) existing.update( { "source_event_key": source_event_key, @@ -506,10 +546,17 @@ def _update_deferred_gap( "failure_kind": failure_kind, } ) + changed = previous != existing review_data["deferred_gaps"][source_event_key] = existing + return changed -def _validate_live_comment_replay_contract(bot, review_data: dict, payload: dict, live_body: str) -> dict | None: +def _validate_live_comment_replay_contract( + bot, + review_data: dict, + payload: dict, + live_body: str, +) -> LiveCommentReplayValidationResult: source_comment_class = str(payload.get("comment_class", "")) live_classified = classify_comment_payload(bot, live_body) live_comment_class = str(live_classified.get("comment_class", "")) @@ -517,7 +564,7 @@ def _validate_live_comment_replay_contract(bot, review_data: dict, payload: dict live_has_non_command_text = bool(live_classified.get("has_non_command_text")) if live_comment_class != source_comment_class: - _update_deferred_gap( + changed = _update_deferred_gap( bot, review_data, payload, @@ -528,10 +575,10 @@ def _validate_live_comment_replay_contract(bot, review_data: dict, payload: dict f"See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." ), ) - return None + return LiveCommentReplayValidationResult(None, changed, True) if live_has_non_command_text != source_has_non_command_text: - _update_deferred_gap( + changed = _update_deferred_gap( bot, review_data, payload, @@ -541,10 +588,10 @@ def _validate_live_comment_replay_contract(bot, review_data: dict, payload: dict f"replay suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." ), ) - return None + return LiveCommentReplayValidationResult(None, changed, True) if source_comment_class in {"command_only", "command_plus_text"} and int(live_classified.get("command_count", 0)) != 1: - _update_deferred_gap( + changed = _update_deferred_gap( bot, review_data, payload, @@ -554,9 +601,9 @@ def _validate_live_comment_replay_contract(bot, review_data: dict, payload: dict f"replay suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}." ), ) - return None + return LiveCommentReplayValidationResult(None, changed, True) - return live_classified + return LiveCommentReplayValidationResult(live_classified, False, False) def handle_workflow_run_event(bot, state: dict) -> bool: @@ -653,9 +700,9 @@ def handle_workflow_run_event(bot, state: dict) -> bool: state_changed = True if _record_review_rebuild(bot, state, pr_number, review_data): state_changed = True - _mark_reconciled_source_event(review_data, source_event_key) - _clear_source_event_key(review_data, source_event_key) - return state_changed + reconciled_changed = _mark_reconciled_source_event(review_data, source_event_key) + gap_cleared_changed = _clear_source_event_key(review_data, source_event_key) + return state_changed or reconciled_changed or gap_cleared_changed if event_name == "pull_request_review" and event_action == "dismissed": _validate_deferred_review_artifact(payload) @@ -680,7 +727,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: return True except RuntimeError as exc: failure_kind = exc.failure_kind if isinstance(exc, ReconcileReadError) else None - _update_deferred_gap( + gap_changed = _update_deferred_gap( bot, review_data, payload, @@ -688,5 +735,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: f"{exc} See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.", failure_kind=failure_kind, ) + if gap_changed: + return True raise raise RuntimeError("Unsupported deferred workflow_run payload") diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index f0c1c1a3b..b1ee686e4 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -224,8 +224,6 @@ def get_preferred_current_reviewer_review_for_cycle( return None if len(valid_reviews) == 1: return valid_reviews[0] - if pull_request is None: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") head = pull_request.get("head") if isinstance(pull_request, dict) else None current_head = head.get("sha") if isinstance(head, dict) else None current_head_reviews = [review for review in valid_reviews if _review_matches_head(review, current_head)] @@ -279,6 +277,11 @@ def refresh_reviewer_review_from_live_preferred_review( reviews: list[dict] | None = None, actor: str | None = None, ) -> tuple[bool, dict | None]: + if pull_request is None: + pull_request_result = _pull_request_read_result(bot, issue_number) + if not pull_request_result.get("ok"): + return False, None + pull_request = pull_request_result["pull_request"] preferred_review = get_preferred_current_reviewer_review_for_cycle( bot, issue_number, @@ -301,10 +304,131 @@ def refresh_reviewer_review_from_live_preferred_review( changed = True submitted_at = preferred_review.get("submitted_at") if isinstance(submitted_at, str): + previous_activity = review_data.get("last_reviewer_activity") + previous_warning = review_data.get("transition_warning_sent") + previous_notice = review_data.get("transition_notice_sent_at") record_reviewer_activity(review_data, submitted_at) + activity_changed = ( + previous_activity != review_data.get("last_reviewer_activity") + or previous_warning != review_data.get("transition_warning_sent") + or previous_notice != review_data.get("transition_notice_sent_at") + ) + changed = changed or activity_changed return changed, preferred_review +def compute_pr_approval_state_result( + bot, + issue_number: int, + review_data: dict, + *, + pull_request: dict | None = None, + reviews: list[dict] | None = None, +) -> dict[str, object]: + boundary = get_current_cycle_boundary(bot, review_data) + if boundary is None: + return _projection_failure("pull_request_unavailable") + pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) + if not pull_request_result.get("ok"): + return pull_request_result + pull_request = pull_request_result["pull_request"] + head = pull_request.get("head") + current_head = head.get("sha") if isinstance(head, dict) else None + if not isinstance(current_head, str) or not current_head.strip(): + return _projection_failure("pull_request_head_unavailable", "invalid_payload") + reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) + if not reviews_result.get("ok"): + return reviews_result + reviews = reviews_result["reviews"] + + survivors: dict[str, dict] = {} + for review in reviews: + if not isinstance(review, dict): + continue + state = str(review.get("state", "")).upper() + if state == "DISMISSED": + continue + submitted_at = parse_github_timestamp(review.get("submitted_at")) + if submitted_at is None or submitted_at < boundary: + continue + commit_id = review.get("commit_id") + if not isinstance(commit_id, str) or not commit_id.strip(): + continue + if commit_id.strip() != current_head: + continue + author = review.get("user", {}).get("login") + if not isinstance(author, str) or not author.strip(): + continue + review_id = str(review.get("id", "")) + key = author.lower() + candidate_key = (submitted_at, review_id) + current = survivors.get(key) + if current is None: + survivors[key] = review + continue + current_key = ( + parse_github_timestamp(current.get("submitted_at")) + or datetime.min.replace(tzinfo=timezone.utc), + str(current.get("id", "")), + ) + if candidate_key >= current_key: + survivors[key] = review + + approvals = [review for review in survivors.values() if str(review.get("state", "")).upper() == "APPROVED"] + completion = { + "completed": bool(approvals), + "current_head_sha": current_head, + "qualifying_review_ids": [review.get("id") for review in approvals], + } + + permission_cache: dict[str, str] = {} + has_write_approval = False + write_approvers: list[str] = [] + for review in approvals: + author = review.get("user", {}).get("login") + if not isinstance(author, str) or not author.strip(): + continue + cache_key = author.lower() + if cache_key not in permission_cache: + permission_cache[cache_key] = _permission_status(bot, author, "push") + if permission_cache[cache_key] == "unavailable": + return _projection_failure("permission_unavailable") + if permission_cache[cache_key] == "granted": + has_write_approval = True + write_approvers.append(author) + write_approval = { + "has_write_approval": has_write_approval, + "write_approvers": write_approvers, + "current_head_sha": current_head, + } + return { + "ok": True, + "completion": completion, + "write_approval": write_approval, + "current_head_sha": current_head, + } + + +def apply_pr_approval_state( + review_data: dict, + *, + completion: dict, + write_approval: dict, + current_head_sha: str, +) -> None: + review_data["active_head_sha"] = current_head_sha + review_data["current_cycle_completion"] = completion + review_data["current_cycle_write_approval"] = write_approval + if completion.get("completed"): + review_data["review_completed_at"] = _now_iso() + review_data["review_completed_by"] = None + review_data["review_completion_source"] = "live_review_rebuild" + else: + review_data["review_completed_at"] = None + review_data["review_completed_by"] = None + review_data["review_completion_source"] = None + + def repair_missing_reviewer_review_state(bot, issue_number: int, review_data: dict, *, reviews: list[dict] | None = None) -> bool: changed, _ = refresh_reviewer_review_from_live_preferred_review( bot, @@ -690,95 +814,27 @@ def rebuild_pr_approval_state_result( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> dict[str, object]: - boundary = get_current_cycle_boundary(bot, review_data) - if boundary is None: - return _projection_failure("pull_request_unavailable") - pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) - if not pull_request_result.get("ok"): - return pull_request_result - pull_request = pull_request_result["pull_request"] - head = pull_request.get("head") - current_head = head.get("sha") if isinstance(head, dict) else None - if not isinstance(current_head, str) or not current_head.strip(): - return _projection_failure("pull_request_head_unavailable", "invalid_payload") - reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) - if not reviews_result.get("ok"): - return reviews_result - reviews = reviews_result["reviews"] - - survivors: dict[str, dict] = {} - for review in reviews: - if not isinstance(review, dict): - continue - state = str(review.get("state", "")).upper() - if state == "DISMISSED": - continue - submitted_at = parse_github_timestamp(review.get("submitted_at")) - if submitted_at is None or submitted_at < boundary: - continue - commit_id = review.get("commit_id") - if not isinstance(commit_id, str) or not commit_id.strip(): - continue - if commit_id.strip() != current_head: - continue - author = review.get("user", {}).get("login") - if not isinstance(author, str) or not author.strip(): - continue - review_id = str(review.get("id", "")) - key = author.lower() - candidate_key = (submitted_at, review_id) - current = survivors.get(key) - if current is None: - survivors[key] = review - continue - current_key = ( - parse_github_timestamp(current.get("submitted_at")) or datetime.min.replace(tzinfo=timezone.utc), - str(current.get("id", "")), - ) - if candidate_key >= current_key: - survivors[key] = review - - approvals = [review for review in survivors.values() if str(review.get("state", "")).upper() == "APPROVED"] - completion = { - "completed": bool(approvals), - "current_head_sha": current_head, - "qualifying_review_ids": [review.get("id") for review in approvals], - } - - permission_cache: dict[str, str] = {} - has_write_approval = False - write_approvers: list[str] = [] - for review in approvals: - author = review.get("user", {}).get("login") - if not isinstance(author, str) or not author.strip(): - continue - cache_key = author.lower() - if cache_key not in permission_cache: - permission_cache[cache_key] = _permission_status(bot, author, "push") - if permission_cache[cache_key] == "unavailable": - return _projection_failure("permission_unavailable") - if permission_cache[cache_key] == "granted": - has_write_approval = True - write_approvers.append(author) - write_approval = { - "has_write_approval": has_write_approval, - "write_approvers": write_approvers, - "current_head_sha": current_head, + result = compute_pr_approval_state_result( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if not result.get("ok"): + return result + apply_pr_approval_state( + review_data, + completion=result["completion"], + write_approval=result["write_approval"], + current_head_sha=str(result["current_head_sha"]), + ) + return { + "ok": True, + "completion": result["completion"], + "write_approval": result["write_approval"], } - review_data["active_head_sha"] = current_head - review_data["current_cycle_completion"] = completion - review_data["current_cycle_write_approval"] = write_approval - if completion["completed"]: - review_data["review_completed_at"] = _now_iso() - review_data["review_completed_by"] = None - review_data["review_completion_source"] = "live_review_rebuild" - else: - review_data["review_completed_at"] = None - review_data["review_completed_by"] = None - review_data["review_completion_source"] = None - return {"ok": True, "completion": completion, "write_approval": write_approval} - def pr_has_current_write_approval( bot, @@ -886,6 +942,15 @@ def compute_reviewer_response_state( } return {"state": "done", "reason": None} + pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) + if not pull_request_result.get("ok"): + return {"state": "projection_failed", "reason": str(pull_request_result.get("reason"))} + pull_request = pull_request_result["pull_request"] + head = pull_request.get("head") + current_head = head.get("sha") if isinstance(head, dict) else None + if not isinstance(current_head, str) or not current_head.strip(): + return {"state": "projection_failed", "reason": "pull_request_head_unavailable"} + if not reviewer_comment and not reviewer_review: reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) if not reviews_result.get("ok"): @@ -911,18 +976,17 @@ def compute_reviewer_response_state( "contributor_handoff": None, } - pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) - if not pull_request_result.get("ok"): - return {"state": "projection_failed", "reason": str(pull_request_result.get("reason"))} - pull_request = pull_request_result["pull_request"] - head = pull_request.get("head") - current_head = head.get("sha") if isinstance(head, dict) else None - if not isinstance(current_head, str) or not current_head.strip(): - return {"state": "projection_failed", "reason": "pull_request_head_unavailable"} - review_data["active_head_sha"] = current_head + stored_review_head = reviewer_review.get("reviewed_head_sha") if isinstance(reviewer_review, dict) else None + refresh_live_review = reviews is not None or reviewer_review is None + if not refresh_live_review: + refresh_live_review = not isinstance(stored_review_head, str) or stored_review_head != current_head preferred_live_review = None - if reviewer_review is None or reviews is not None: + if refresh_live_review: + reviews_result = get_pull_request_reviews_result(bot, issue_number, reviews) + if not reviews_result.get("ok"): + return {"state": "projection_failed", "reason": str(reviews_result.get("reason"))} + reviews = reviews_result["reviews"] preferred_live_review = get_preferred_current_reviewer_review_for_cycle( bot, issue_number, @@ -932,6 +996,8 @@ def compute_reviewer_response_state( ) if preferred_live_review is not None: reviewer_review = build_reviewer_review_record_from_live_review(preferred_live_review, actor=current_reviewer) + elif refresh_live_review: + reviewer_review = None latest_reviewer_response = reviewer_comment if _compare_records(reviewer_review, latest_reviewer_response) > 0: @@ -942,12 +1008,14 @@ def compute_reviewer_response_state( if _compare_records(contributor_revision, contributor_handoff) > 0: contributor_handoff = contributor_revision - latest_review_head = reviewer_review.get("reviewed_head_sha") if isinstance(reviewer_review, dict) else None - if not isinstance(latest_review_head, str) or latest_review_head != current_head: + if _compare_cross_channel_conversation(contributor_handoff, latest_reviewer_response) > 0: + reason = "contributor_comment_newer" + if isinstance(contributor_handoff, dict) and str(contributor_handoff.get("semantic_key", "")).startswith("pull_request_"): + reason = "contributor_revision_newer" return { "state": "awaiting_reviewer_response", - "reason": "review_head_stale", - "anchor_timestamp": contributor_handoff.get("timestamp") if isinstance(contributor_handoff, dict) else _initial_reviewer_anchor(review_data), + "reason": reason, + "anchor_timestamp": contributor_handoff.get("timestamp") if isinstance(contributor_handoff, dict) else None, "current_head_sha": current_head, "reviewer_comment": reviewer_comment, "reviewer_review": reviewer_review, @@ -955,14 +1023,12 @@ def compute_reviewer_response_state( "contributor_handoff": contributor_handoff, } - if _compare_cross_channel_conversation(contributor_handoff, latest_reviewer_response) > 0: - reason = "contributor_comment_newer" - if isinstance(contributor_handoff, dict) and str(contributor_handoff.get("semantic_key", "")).startswith("pull_request_"): - reason = "contributor_revision_newer" + latest_review_head = reviewer_review.get("reviewed_head_sha") if isinstance(reviewer_review, dict) else None + if not isinstance(latest_review_head, str) or latest_review_head != current_head: return { "state": "awaiting_reviewer_response", - "reason": reason, - "anchor_timestamp": contributor_handoff.get("timestamp") if isinstance(contributor_handoff, dict) else None, + "reason": "review_head_stale", + "anchor_timestamp": contributor_handoff.get("timestamp") if isinstance(contributor_handoff, dict) else _initial_reviewer_anchor(review_data), "current_head_sha": current_head, "reviewer_comment": reviewer_comment, "reviewer_review": reviewer_review, @@ -981,7 +1047,7 @@ def compute_reviewer_response_state( if completion is None or write_approval is None: return {"state": "projection_failed", "reason": "live_review_state_unknown"} else: - approval_result = rebuild_pr_approval_state_result( + approval_result = compute_pr_approval_state_result( bot, issue_number, review_data, diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index c9d61ec5b..52501d69d 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -583,9 +583,9 @@ def _repair_visible_review_gap(bot, review_data: dict, issue_number: int, source )[0] or changed bot.reviews_module.record_reviewer_activity(review_data, submitted_at) completion, _ = bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data) - _mark_reconciled_source_event(review_data, source_event_key) - _clear_source_event_key(review_data, source_event_key) - return changed or completion is not None + reconciled_changed = _mark_reconciled_source_event(review_data, source_event_key) + gap_cleared_changed = _clear_source_event_key(review_data, source_event_key) + return changed or completion is not None or reconciled_changed or gap_cleared_changed def _discover_visible_comment_events(bot, issue_number: int, review_data: dict) -> tuple[list[dict] | None, bool]: From ba4b842d662f4fe566420d142cc3c44e85f67e41 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 04:56:49 +0900 Subject: [PATCH 003/209] test(reviewer-bot): split reviewer bot test suite --- .github/reviewer-bot-tests/conftest.py | 28 +- .github/reviewer-bot-tests/factories.py | 97 + .github/reviewer-bot-tests/fakes.py | 12 + .../{test_main.py => test_app.py} | 370 +- .github/reviewer-bot-tests/test_commands.py | 416 ++ .../test_comment_routing.py | 264 + .github/reviewer-bot-tests/test_github_api.py | 400 ++ .github/reviewer-bot-tests/test_lease_lock.py | 320 + .github/reviewer-bot-tests/test_lifecycle.py | 434 ++ .../reviewer-bot-tests/test_maintenance.py | 154 + .github/reviewer-bot-tests/test_overdue.py | 249 + .../reviewer-bot-tests/test_project_board.py | 244 + .github/reviewer-bot-tests/test_reconcile.py | 1310 ++++ .../reviewer-bot-tests/test_reviewer_bot.py | 5387 ----------------- .../test_reviewer_bot_adapter.py | 38 + .github/reviewer-bot-tests/test_reviews.py | 877 +++ .../reviewer-bot-tests/test_state_store.py | 104 + .github/reviewer-bot-tests/test_sweeper.py | 464 ++ 18 files changed, 5458 insertions(+), 5710 deletions(-) create mode 100644 .github/reviewer-bot-tests/factories.py create mode 100644 .github/reviewer-bot-tests/fakes.py rename .github/reviewer-bot-tests/{test_main.py => test_app.py} (84%) create mode 100644 .github/reviewer-bot-tests/test_commands.py create mode 100644 .github/reviewer-bot-tests/test_comment_routing.py create mode 100644 .github/reviewer-bot-tests/test_github_api.py create mode 100644 .github/reviewer-bot-tests/test_lease_lock.py create mode 100644 .github/reviewer-bot-tests/test_lifecycle.py create mode 100644 .github/reviewer-bot-tests/test_maintenance.py create mode 100644 .github/reviewer-bot-tests/test_overdue.py create mode 100644 .github/reviewer-bot-tests/test_project_board.py create mode 100644 .github/reviewer-bot-tests/test_reconcile.py delete mode 100644 .github/reviewer-bot-tests/test_reviewer_bot.py create mode 100644 .github/reviewer-bot-tests/test_reviewer_bot_adapter.py create mode 100644 .github/reviewer-bot-tests/test_reviews.py create mode 100644 .github/reviewer-bot-tests/test_state_store.py create mode 100644 .github/reviewer-bot-tests/test_sweeper.py diff --git a/.github/reviewer-bot-tests/conftest.py b/.github/reviewer-bot-tests/conftest.py index fdd82486b..2757cc93d 100644 --- a/.github/reviewer-bot-tests/conftest.py +++ b/.github/reviewer-bot-tests/conftest.py @@ -4,7 +4,7 @@ @pytest.fixture(autouse=True) -def clear_env(): +def clear_reviewer_bot_env(): env_vars = { "COMMENT_BODY", "COMMENT_AUTHOR", @@ -48,9 +48,35 @@ def clear_env(): ), ) monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) yield +@pytest.fixture +def setenv_many(monkeypatch): + def setter(values: dict[str, str]): + for key, value in values.items(): + monkeypatch.setenv(key, value) + + return setter + + +@pytest.fixture +def lease_context(): + return reviewer_bot.LeaseContext( + lock_token="test-lock-token", + lock_owner_run_id="test-run", + lock_owner_workflow="test-workflow", + lock_owner_job="test-job", + state_issue_url="https://example.com/state", + ) + + +@pytest.fixture +def tmp_deferred_path(tmp_path): + return tmp_path / "deferred-context.json" + + @pytest.fixture def stub_api(monkeypatch): monkeypatch.setattr(reviewer_bot, "github_api", lambda *args, **kwargs: {}) diff --git a/.github/reviewer-bot-tests/factories.py b/.github/reviewer-bot-tests/factories.py new file mode 100644 index 000000000..616f15e83 --- /dev/null +++ b/.github/reviewer-bot-tests/factories.py @@ -0,0 +1,97 @@ +import io +import json +import zipfile + +from scripts import reviewer_bot + + +def make_state(epoch: str | None = None): + state = { + "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, + "freshness_runtime_epoch": epoch or reviewer_bot.FRESHNESS_RUNTIME_EPOCH_V18, + "status_projection_epoch": reviewer_bot.STATUS_PROJECTION_EPOCH, + "last_updated": None, + "current_index": 0, + "queue": [ + {"github": "alice", "name": "Alice"}, + {"github": "bob", "name": "Bob"}, + {"github": "carol", "name": "Carol"}, + ], + "pass_until": [], + "recent_assignments": [], + "active_reviews": {}, + } + if epoch == "freshness_v15": + state["freshness_runtime_epoch"] = epoch + state.pop("status_projection_epoch", None) + return state + + +def valid_reviewer_board_metadata(): + return { + "data": { + "organization": { + "projectV2": { + "id": "PVT_kwDOB", + "title": "Reviewer Board", + "fields": { + "nodes": [ + { + "__typename": "ProjectV2SingleSelectField", + "id": "field-review-state", + "name": "Review State", + "options": [ + {"id": "opt-ar", "name": "Awaiting Reviewer"}, + {"id": "opt-ac", "name": "Awaiting Contributor"}, + {"id": "opt-aw", "name": "Awaiting Write Approval"}, + {"id": "opt-done", "name": "Done"}, + {"id": "opt-unassigned", "name": "Unassigned"}, + ], + }, + { + "__typename": "ProjectV2Field", + "dataType": "TEXT", + "id": "field-reviewer", + "name": "Reviewer", + }, + { + "__typename": "ProjectV2Field", + "dataType": "DATE", + "id": "field-assigned-at", + "name": "Assigned At", + }, + { + "__typename": "ProjectV2Field", + "dataType": "DATE", + "id": "field-waiting-since", + "name": "Waiting Since", + }, + { + "__typename": "ProjectV2SingleSelectField", + "id": "field-needs-attention", + "name": "Needs Attention", + "options": [ + {"id": "opt-no", "name": "No"}, + {"id": "opt-warning", "name": "Warning Sent"}, + {"id": "opt-notice", "name": "Transition Notice Sent"}, + {"id": "opt-triage", "name": "Triage Approval Required"}, + {"id": "opt-repair", "name": "Projection Repair Required"}, + ], + }, + ] + }, + } + } + } + } + + +def iso_z(dt): + return dt.isoformat().replace("+00:00", "Z") + + +def make_zip_payload(file_name: str, payload: dict) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(file_name, json.dumps(payload)) + return buffer.getvalue() diff --git a/.github/reviewer-bot-tests/fakes.py b/.github/reviewer-bot-tests/fakes.py new file mode 100644 index 000000000..26e17e48c --- /dev/null +++ b/.github/reviewer-bot-tests/fakes.py @@ -0,0 +1,12 @@ +class FakeGitHubResponse: + def __init__(self, status_code, payload=None, text="", headers=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + self.content = b"" if payload is None and not text else b"x" + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload diff --git a/.github/reviewer-bot-tests/test_main.py b/.github/reviewer-bot-tests/test_app.py similarity index 84% rename from .github/reviewer-bot-tests/test_main.py rename to .github/reviewer-bot-tests/test_app.py index 96567c10e..6685408a2 100644 --- a/.github/reviewer-bot-tests/test_main.py +++ b/.github/reviewer-bot-tests/test_app.py @@ -1,57 +1,13 @@ import json import pytest +from factories import make_state +from factories import valid_reviewer_board_metadata as _valid_reviewer_board_metadata from scripts import reviewer_bot -from scripts.reviewer_bot_lib.context import ( - GitHubTransportContext, - LeaseLockContext, - ReviewerBotContext, - StateStoreContext, -) from scripts.reviewer_bot_lib.lifecycle import HeadObservationRepairResult -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": [ - {"github": "alice", "name": "Alice"}, - {"github": "bob", "name": "Bob"}, - {"github": "carol", "name": "Carol"}, - ], - "pass_until": [], - "recent_assignments": [], - "active_reviews": {}, - } - - -def test_reviewer_bot_exports_runtime_modules(): - assert reviewer_bot.requests is not None - assert reviewer_bot.sys is not None - assert reviewer_bot.datetime is not None - assert reviewer_bot.timezone is not None - - -def test_reviewer_bot_satisfies_runtime_context_protocol(): - assert isinstance(reviewer_bot, ReviewerBotContext) - - -def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): - assert isinstance(reviewer_bot, GitHubTransportContext) - assert isinstance(reviewer_bot, StateStoreContext) - assert isinstance(reviewer_bot, LeaseLockContext) - - -def test_render_lock_commit_message_uses_direct_json_import(): - rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) - assert reviewer_bot.LOCK_COMMIT_MARKER in rendered - - def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -64,100 +20,34 @@ def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): assert "Current state:" in output assert "freshness_runtime_epoch" in output - -def _valid_reviewer_board_metadata(): - return { - "data": { - "organization": { - "projectV2": { - "id": "PVT_kwDOB", - "title": "Reviewer Board", - "fields": { - "nodes": [ - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-review-state", - "name": "Review State", - "options": [ - {"id": "opt-ar", "name": "Awaiting Reviewer"}, - {"id": "opt-ac", "name": "Awaiting Contributor"}, - {"id": "opt-aw", "name": "Awaiting Write Approval"}, - {"id": "opt-done", "name": "Done"}, - {"id": "opt-unassigned", "name": "Unassigned"}, - ], - }, - { - "__typename": "ProjectV2Field", - "dataType": "TEXT", - "id": "field-reviewer", - "name": "Reviewer", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-assigned-at", - "name": "Assigned At", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-waiting-since", - "name": "Waiting Since", - }, - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-needs-attention", - "name": "Needs Attention", - "options": [ - {"id": "opt-no", "name": "No"}, - {"id": "opt-warning", "name": "Warning Sent"}, - {"id": "opt-notice", "name": "Transition Notice Sent"}, - {"id": "opt-triage", "name": "Triage Approval Required"}, - {"id": "opt-repair", "name": "Projection Repair Required"}, - ], - }, - ] - }, - } - } - } - } - - def test_classify_event_intent_cross_repo_review_is_non_mutating_defer(monkeypatch): monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - def test_classify_event_intent_preview_reviewer_board_is_non_mutating(monkeypatch): monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") intent = reviewer_bot.classify_event_intent("workflow_dispatch", "") assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY - def test_classify_event_intent_same_repo_review_is_non_mutating_defer(monkeypatch): intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - def test_classify_event_intent_same_repo_dismissed_review_is_non_mutating_defer(monkeypatch): intent = reviewer_bot.classify_event_intent("pull_request_review", "dismissed") assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - def test_classify_event_intent_review_comment_is_non_mutating_defer(monkeypatch): intent = reviewer_bot.classify_event_intent("pull_request_review_comment", "created") assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - def test_classify_event_intent_workflow_run_dismissed_review_is_mutating(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "dismissed") intent = reviewer_bot.classify_event_intent("workflow_run", "completed") assert intent == reviewer_bot.EVENT_INTENT_MUTATING - def test_main_cross_repo_review_does_not_acquire_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") @@ -177,7 +67,6 @@ def fail_if_called(): assert acquire_called["value"] is False - def test_main_same_repo_review_does_not_acquire_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") @@ -198,7 +87,6 @@ def fail_if_called(): assert acquire_called["value"] is False - def test_main_workflow_run_reconcile_acquires_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -229,7 +117,6 @@ def fake_acquire(): assert acquire_called["value"] is True - def test_main_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -260,7 +147,6 @@ def fake_acquire(): assert acquire_called["value"] is True - def test_main_reloads_state_before_syncing_status_labels(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -315,7 +201,6 @@ def fake_sync_status_labels_for_items(state, issue_numbers): "sync", ] - def test_main_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -354,7 +239,6 @@ def test_main_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys) output = capsys.readouterr().out assert "Reviewer board preview skipped: reviewer board is disabled." in output - def test_main_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -385,7 +269,6 @@ def test_main_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, ca assert excinfo.value.code == 1 assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err - def test_main_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -422,7 +305,6 @@ def test_main_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, assert excinfo.value.code == 1 assert "Missing reviewer board field: Review State" in capsys.readouterr().err - def test_main_preview_reviewer_board_is_read_only(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -479,7 +361,6 @@ def test_main_preview_reviewer_board_is_read_only(monkeypatch, capsys): assert "classification: open_tracked_assigned" in output assert "ensure_membership: true" in output - def test_issue_close_then_close_comment_does_not_leave_active_review(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -503,7 +384,6 @@ def test_issue_close_then_close_comment_does_not_leave_active_review(monkeypatch assert reviewer_bot.handle_comment_event(state) is False assert "42" not in state["active_reviews"] - def test_main_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -555,7 +435,6 @@ def fake_load_state(*, fail_on_unavailable=False): assert sync_calls[0][0] is reloaded_state assert sync_calls[0][1] == [42] - def test_main_closed_issue_comment_without_entry_skips_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -594,7 +473,6 @@ def test_main_closed_issue_comment_without_entry_skips_save(monkeypatch): assert save_called["value"] is False assert sync_calls == [[42]] - def test_main_label_signoff_issue_saves_before_status_projection(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -646,7 +524,6 @@ def fake_load_state(*, fail_on_unavailable=False): assert initial_state["active_reviews"]["42"]["review_completion_source"] == "issue_label: sign-off: create pr" assert call_order == ["save", "sync"] - def test_main_label_signoff_issue_does_not_project_labels_when_save_fails(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -687,7 +564,6 @@ def test_main_label_signoff_issue_does_not_project_labels_when_save_fails(monkey assert excinfo.value.code == 1 - def test_main_fails_when_save_state_fails(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -704,7 +580,6 @@ def test_main_fails_when_save_state_fails(monkeypatch): assert excinfo.value.code == 1 - def test_main_workflow_run_fails_closed_on_invalid_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -726,7 +601,6 @@ def test_main_workflow_run_fails_closed_on_invalid_context(monkeypatch): assert excinfo.value.code == 1 - def test_main_schedule_backfills_existing_transition_notice_without_duplicate_comment(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -793,7 +667,6 @@ def fake_api(method, endpoint, data=None): assert posted == [] assert save_calls == ["2026-03-25T15:22:42Z"] - def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -863,7 +736,6 @@ def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypa assert synced_issue_numbers == [42] assert len(save_calls) == 1 - def test_main_schedule_structured_head_repair_result_without_mutation_skips_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -914,7 +786,6 @@ def test_main_schedule_structured_head_repair_result_without_mutation_skips_save assert save_called["value"] is False - def test_main_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -949,7 +820,6 @@ def test_main_schedule_status_projection_epoch_mismatch_triggers_label_repair_sw 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", "") @@ -981,7 +851,6 @@ def test_main_schedule_status_projection_epoch_not_advanced_on_label_sync_failur 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", "") @@ -1019,7 +888,6 @@ def test_main_schedule_projection_epoch_repair_relabels_previously_repaired_pr(m 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") @@ -1037,7 +905,6 @@ def fail_load(*, fail_on_unavailable=False): assert excinfo.value.code == 1 - def test_main_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -1088,183 +955,6 @@ def track_save(state): "save": False, } - -def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref-2", "tree", {"lock_state": "unlocked", "lock_token": None}), - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "local-run", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - context = reviewer_bot.acquire_state_issue_lease_lock() - - assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context - - -def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - with pytest.raises(RuntimeError, match="unexpected lock state"): - reviewer_bot.acquire_state_issue_lease_lock() - - -def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "local-run", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - context = reviewer_bot.acquire_state_issue_lease_lock() - - assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context - - -def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "someone-else", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ) - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - with pytest.raises(RuntimeError, match="owner metadata drifted"): - reviewer_bot.acquire_state_issue_lease_lock() - - -def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="token-123", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - snapshots = iter( - [ - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("new-ref", "tree", {"lock_state": "locked", "lock_token": "token-123"}), - ] - ) - - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-2"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - - assert reviewer_bot.release_state_issue_lease_lock() is True - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None - - -def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="token-123", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"})) - - assert reviewer_bot.release_state_issue_lease_lock() is False - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None - - def test_schedule_guard_blocks_empty_active_reviews_wipe(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -1302,7 +992,6 @@ def track_save(_state): assert excinfo.value.code == 1 assert save_called["value"] is False - def test_main_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -1400,7 +1089,6 @@ def fake_github_api(method, endpoint, data=None): assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] assert synced_issue_numbers == [42] - def test_main_pull_request_synchronize_head_only_mutation_still_saves_state(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_target") monkeypatch.setenv("EVENT_ACTION", "synchronize") @@ -1438,7 +1126,6 @@ def test_main_pull_request_synchronize_head_only_mutation_still_saves_state(monk assert save_calls == ["head-2"] - def test_main_schedule_identical_live_read_failure_does_not_repeat_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -1489,7 +1176,6 @@ def test_main_schedule_identical_live_read_failure_does_not_repeat_save(monkeypa assert len(save_snapshots) == 1 assert save_snapshots[0]["kind"] == "live_read_failure" - def test_main_identical_projection_failure_does_not_repeat_repair_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -1532,7 +1218,6 @@ def test_main_identical_projection_failure_does_not_repeat_repair_save(monkeypat assert len(save_markers) == 1 assert save_markers[0]["kind"] == "projection_failure" - def test_main_changed_projection_failure_reason_triggers_new_repair_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -1577,7 +1262,6 @@ def fail_projection(current, issue_numbers): assert save_markers[0]["reason"] == "projection failed" assert save_markers[1]["reason"] == "projection still failed differently" - def test_main_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): @@ -1659,7 +1343,6 @@ def fake_github_api(method, endpoint, data=None): assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] - def test_main_workflow_run_deferred_comment_contract_drift_persists_gap_update( tmp_path, monkeypatch ): @@ -1748,7 +1431,6 @@ def fake_github_api(method, endpoint, data=None): assert save_snapshots[0]["reason"] == "reconcile_failed_closed" - def test_main_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): @@ -1833,7 +1515,6 @@ def fake_github_api(method, endpoint, data=None): {"reconciled": ["pull_request_review_comment:310"], "gap_present": False} ] - def test_main_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -1888,7 +1569,6 @@ def fake_sweep(bot, current): assert save_calls == [["pull_request_review:500"]] - def test_main_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -1990,3 +1670,49 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k "transition_notice_sent_at": None, } ] + +def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") + assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING + +def test_main_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "plain text") + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + saved_states = [] + + def fake_load_state(*, fail_on_unavailable=False): + return json.loads(json.dumps(state)) + + def fake_save_state(updated_state): + saved_states.append(json.loads(json.dumps(updated_state))) + state.clear() + state.update(json.loads(json.dumps(updated_state))) + return True + + monkeypatch.setattr(reviewer_bot, "load_state", fake_load_state) + monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current_state: (current_state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current_state: (current_state, [])) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed"))) + output_path = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) + reviewer_bot.app_module.main(reviewer_bot) + assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" + assert len(saved_states) >= 2 diff --git a/.github/reviewer-bot-tests/test_commands.py b/.github/reviewer-bot-tests/test_commands.py new file mode 100644 index 000000000..2d85d9469 --- /dev/null +++ b/.github/reviewer-bot-tests/test_commands.py @@ -0,0 +1,416 @@ +import json +import subprocess +from pathlib import Path + +import pytest +from factories import make_state + +from builder import build_cli +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing + + +def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") + monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) + monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda *args, **kwargs: pytest.fail( + "status sync should run only from app orchestration after save" + ), + ) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + assert reviewer_bot.handle_comment_event(state) is True + assert review["review_completion_source"] == "issue_label: sign-off: create pr" + assert review["current_cycle_completion"]["completed"] is True + assert posted == [(42, "✅ Added label `sign-off: create pr`")] + +def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "dana"}, + }, + ) + monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) + monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command")) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + assert reviewer_bot.handle_comment_event(state) is False + assert review["review_completion_source"] is None + +def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): + called = {"post": 0} + monkeypatch.setattr(reviewer_bot, "find_open_pr_for_branch_status", lambda branch: ("unavailable", None)) + monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None) + + with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): + reviewer_bot.create_pull_request("feature-branch", "main", 42) + + assert called["post"] == 0 + +def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "F\u00e9lix Fischer"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_assign_command_posts_pr_guidance_on_success(monkeypatch): + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + assert success is True + assert response == "✅ @felix91gr has been assigned as reviewer." + assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + +def test_claim_command_posts_pr_guidance_on_success(monkeypatch): + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_claim_command(state, 42, "felix91gr") + assert success is True + assert response == "✅ @felix91gr has claimed this review." + assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + +def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): + state = make_state() + state["queue"] = [ + {"github": "alice", "name": "Alice"}, + {"github": "felix91gr", "name": "Félix Fischer"}, + ] + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: ["alice"]) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: True) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + assert success is True + assert "@felix91gr is now assigned as the reviewer." in response + assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + +def test_assign_from_queue_posts_guidance_only_once(monkeypatch): + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + assert success is True + assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." + assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + +def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): + commands_seen = [] + + def fake_run_command(command, cwd, check=True): + commands_seen.append(command) + if command == ["git", "diff", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if command == ["git", "diff", "--cached", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) + assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] + assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] + +def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): + def fake_run_command(command, cwd, check=True): + if command == ["git", "diff", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="README.md\nsrc/spec.lock\n", stderr="") + if command == ["git", "diff", "--cached", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="src/spec.lock\n", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) + assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] + +def test_privileged_commands_workflow_executes_source_entrypoint(): + workflow_text = Path(".github/workflows/reviewer-bot-privileged-commands.yml").read_text(encoding="utf-8") + assert "Fetch trusted bot source tarball" in workflow_text + assert 'REVIEWER_BOT_TARGET_REPO_ROOT: ${{ github.workspace }}' in workflow_text + assert 'run: uv run --project "$BOT_SRC_ROOT" python "$BOT_SRC_ROOT/scripts/reviewer_bot.py"' in workflow_text + +def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + observed = {"cwd": None} + + def fake_list_changed_files(repo_root): + observed["cwd"] = repo_root + return ["README.md"] + + monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) + assert observed["cwd"] == tmp_path + +def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + list_calls = {"count": 0} + + def fake_list_changed_files(repo_root): + list_calls["count"] += 1 + assert repo_root == tmp_path + return [] + + commands = [] + + def fake_run_command(command, cwd, check=False): + commands.append((command, cwd, check)) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) + assert list_calls["count"] == 2 + assert commands == [ + (["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], tmp_path, False), + (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), + ] + +def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) + + def fake_run_command(command, cwd, check=False): + return subprocess.CompletedProcess( + command, + 1, + stdout="", + stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", + ) + + monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + assert success is False + assert "Audit command failed." in message + assert "--locked was provided" in message + +def test_update_spec_lock_file_mode_exits_before_build_docs(monkeypatch, tmp_path): + monkeypatch.setattr(build_cli.argparse.ArgumentParser, "parse_args", lambda self: type("Args", (), { + "clear": False, + "offline": False, + "ignore_spec_lock_diff": False, + "update_spec_lock_file": True, + "validate_urls": False, + "serve": False, + "check_links": False, + "xml": False, + "verbose": False, + "debug": False, + })()) + called = {"update": 0, "build": 0} + monkeypatch.setattr(build_cli, "update_spec_lockfile", lambda url, path: called.__setitem__("update", called["update"] + 1) or True) + monkeypatch.setattr(build_cli, "build_docs", lambda *args, **kwargs: called.__setitem__("build", called["build"] + 1)) + with pytest.raises(SystemExit) as exc_info: + build_cli.main(tmp_path) + assert exc_info.value.code == 0 + assert called == {"update": 1, "build": 0} + +def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + + assert success is False + assert "Unable to verify triage permissions right now" in message + +def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_pass_until_command( + state, + 42, + "alice", + "2099-01-01", + None, + ) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_claim_command(state, 42, "alice") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response, success = reviewer_bot.handle_release_command(state, 42, "alice", ["@bob"]) + + assert success is False + assert "Unable to verify triage permissions right now" in response + +def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_release_command(state, 42, "alice") + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + + assert success is False + assert "Unable to determine current assignees/reviewers" in response + +def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + + assert success is False + assert changed is False + assert "Unable to verify triage permissions right now" in message + +def test_handle_rectify_command_reports_permission_denied(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + + message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + + assert success is False + assert changed is False + assert "Only maintainers with triage+ permission" in message + +def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + ok, metadata = comment_routing._validate_accept_no_fls_changes_handoff( + reviewer_bot, + 42, + "alice", + ) + + assert ok is False + assert metadata["reason"] == "authorization_unavailable" + +def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"] = { + "issue_comment:100": { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + assert reviewer_bot.handle_manual_dispatch(state) is True + pending = review["pending_privileged_commands"]["issue_comment:100"] + assert pending["status"] == "failed_closed" + assert pending["result"] == "live_permission_unavailable" diff --git a/.github/reviewer-bot-tests/test_comment_routing.py b/.github/reviewer-bot-tests/test_comment_routing.py new file mode 100644 index 000000000..a06819d5d --- /dev/null +++ b/.github/reviewer-bot-tests/test_comment_routing.py @@ -0,0 +1,264 @@ +import json +from pathlib import Path + +import pytest +from factories import make_state + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing + + +def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T09:00:00Z", + actor="alice", + ) + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") + + changed = reviewer_bot.comment_routing_module._record_conversation_freshness( + reviewer_bot, + state, + 42, + "alice", + 100, + "2026-03-17T10:00:00Z", + ) + + assert changed is True + assert review["last_reviewer_activity"] == "2026-03-17T10:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({"COMMENT_USER_TYPE": "Bot", "COMMENT_AUTHOR": "reviewer-bot"}, "bot_account"), + ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice"}, "repo_user_principal"), + ({"COMMENT_AUTHOR": "mystery"}, "repo_user_principal"), + ], +) +def test_classify_issue_comment_actor(monkeypatch, env, expected): + for key, value in env.items(): + monkeypatch.setenv(key, value) + assert comment_routing.classify_issue_comment_actor() == expected + +def test_classify_comment_payload_distinguishes_command_plus_text(): + payload = comment_routing.classify_comment_payload(reviewer_bot, "hello\n@guidelines-bot /queue") + assert payload["comment_class"] == "command_plus_text" + assert payload["has_non_command_text"] is True + +def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "carol"}, + }, + ) + assert comment_routing.route_issue_comment_trust(reviewer_bot, 42) == "pr_trusted_direct" + +def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("COMMENT_USER_TYPE", "") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "carol"}, + }, + ) + with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): + comment_routing.route_issue_comment_trust(reviewer_bot, 42) + +def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): + state = make_state() + entry = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert entry is not None + entry["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") + monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + assert reviewer_bot.handle_comment_event(state) is True + pending = state["active_reviews"]["42"]["pending_privileged_commands"] + assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" + assert pending["issue_comment:100"]["authorization"]["authorized"] is True + +def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypatch): + state = make_state() + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + assert reviewer_bot.handle_comment_event(state) is False + assert state["active_reviews"] == {} + +def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): + state = make_state() + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") + called = {"post_comment": 0} + monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: called.__setitem__("post_comment", called["post_comment"] + 1) or True) + assert reviewer_bot.handle_comment_event(state) is False + assert state["active_reviews"] == {} + assert called["post_comment"] == 0 + +def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + assert reviewer_bot.handle_comment_event(state) is True + assert "42" not in state["active_reviews"] + +def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): + state = make_state() + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + + assert reviewer_bot.handle_comment_event(state) is False + assert state["active_reviews"] == {} + +def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "open") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: contributor plain text comment") + assert reviewer_bot.handle_comment_event(state) is True + accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] + assert accepted["semantic_key"] == "issue_comment:100" + +def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): + state = make_state() + reviewer_bot.ensure_review_entry(state, 42, create=True) + payload_path = tmp_path / "observer-noop.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 1, + "kind": "observer_noop", + "reason": "ignored_non_human_automation", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 777, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:111", + "pr_number": 42, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "777") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + assert reviewer_bot.handle_workflow_run_event(state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} + +def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "PLeVasseur") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") + monkeypatch.setenv("COMMENT_SENDER_TYPE", "User") + monkeypatch.setenv("COMMENT_INSTALLATION_ID", "") + monkeypatch.setenv("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_AUTHOR_ID", "123") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") + monkeypatch.setenv("GITHUB_RUN_ID", "999") + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "PLeVasseur"}, + }, + ) + payload = reviewer_bot.build_pr_comment_observer_payload(42) + assert payload["kind"] == "observer_noop" + assert payload["reason"] == "trusted_direct_same_repo_human_comment" + assert payload["source_event_key"] == "issue_comment:100" + +def test_issue_comment_direct_workflow_exports_issue_state(): + workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text(encoding="utf-8") + assert "ISSUE_STATE: ${{ github.event.issue.state }}" in workflow_text diff --git a/.github/reviewer-bot-tests/test_github_api.py b/.github/reviewer-bot-tests/test_github_api.py new file mode 100644 index 000000000..3a3b7f0fd --- /dev/null +++ b/.github/reviewer-bot-tests/test_github_api.py @@ -0,0 +1,400 @@ +import pytest +from fakes import FakeGitHubResponse + +from scripts import reviewer_bot + + +def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + assert result.failure_kind is None + +def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + +def test_github_api_request_classifies_not_found_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(404, {"message": "missing"}, "missing") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + + assert result.ok is False + assert result.failure_kind == "not_found" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + +def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + + assert result.ok is False + assert result.failure_kind == "forbidden" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + +def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"data": {"viewer": {"login": "bot"}}} + assert result.retry_attempts == 1 + +def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.retry_attempts == 1 + +def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "rate_limited" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + +def test_github_graphql_request_reports_invalid_payload(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + +def test_github_graphql_request_reports_graphql_errors(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse( + 200, {"errors": [{"message": "boom"}]}, "boom" + ), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + +def test_github_api_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + observed = {} + + def fake_request(method, url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) + + assert result.ok is True + assert observed["timeout"] == 12.5 + +def test_github_api_request_rejects_idempotent_retry_for_non_get(): + with pytest.raises(ValueError, match="only valid for REST GET"): + reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") + +def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): + with pytest.raises(ValueError, match="only valid for GraphQL queries"): + reviewer_bot.github_graphql_request( + "mutation { closeIssue(input: {}) { clientMutationId } }", + retry_policy="idempotent_read", + ) + +def test_remove_label_reports_failure(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=500, + payload={"message": "boom"}, + headers={}, + text="boom", + ok=False, + failure_kind="server_error", + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False + +def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" + assert reviewer_bot.check_user_permission("alice", "triage") is None + +def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_issue_assignees(42) is None + +def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"not": "a list"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + +def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + +def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_api_request("GET", "issues/42") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + +def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "server_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + +def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + + def always_fail(*args, **kwargs): + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "transport_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert "timeout" in str(result.transport_error) + +def test_github_graphql_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + observed = {} + + def fake_post(url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) + + result = reviewer_bot.github_graphql_request( + "query { viewer { login } }", timeout_seconds=9.5 + ) + + assert result.ok is True + assert observed["timeout"] == 9.5 + +def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): + monkeypatch.delenv("REPO_OWNER", raising=False) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) + +def test_find_open_pr_for_branch_status_reports_found(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + [{"number": 42, "html_url": "https://example.com/pr/42"}], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") + + assert status == "found" + assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} + +def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, [], {}, "ok", True, None, 0, None + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) diff --git a/.github/reviewer-bot-tests/test_lease_lock.py b/.github/reviewer-bot-tests/test_lease_lock.py new file mode 100644 index 000000000..b9e07a4a4 --- /dev/null +++ b/.github/reviewer-bot-tests/test_lease_lock.py @@ -0,0 +1,320 @@ +import pytest + +from scripts import reviewer_bot + + +def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref-2", "tree", {"lock_state": "unlocked", "lock_token": None}), + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "local-run", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ), + ] + ) + + monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + context = reviewer_bot.acquire_state_issue_lease_lock() + + assert context.lock_token == "token-123" + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + +def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), + ] + ) + + monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + with pytest.raises(RuntimeError, match="unexpected lock state"): + reviewer_bot.acquire_state_issue_lease_lock() + +def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "local-run", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ), + ] + ) + + monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + context = reviewer_bot.acquire_state_issue_lease_lock() + + assert context.lock_token == "token-123" + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + +def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "someone-else", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ) + ] + ) + + monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + with pytest.raises(RuntimeError, match="owner metadata drifted"): + reviewer_bot.acquire_state_issue_lease_lock() + +def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="token-123", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + snapshots = iter( + [ + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "token-123"}), + ] + ) + + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-2"}, {}, "", True)) + monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) + + assert reviewer_bot.release_state_issue_lease_lock() is True + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + +def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="token-123", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"})) + + assert reviewer_bot.release_state_issue_lease_lock() is False + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + +def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): + observed = [] + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed.append((endpoint, kwargs.get("retry_policy"))) + if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: + return reviewer_bot.GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/reviewer-bot-state-lock": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/main": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + if endpoint == "git/refs": + return reviewer_bot.GitHubApiResult( + status_code=201, + payload={"ref": "refs/heads/reviewer-bot-state-lock"}, + headers={}, + text="created", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" + assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") + assert observed[1] == ("git/ref/heads/main", "idempotent_read") + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + reviewer_bot.GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): + reviewer_bot.ensure_lock_ref_exists() + +def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"message": "missing tree"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="missing tree SHA"): + reviewer_bot.get_lock_ref_snapshot() + +def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="expected-token", + lock_owner_run_id="run-1", + lock_owner_workflow="wf", + lock_owner_job="job", + state_issue_url="https://example.com/state/1", + ) + monkeypatch.setattr( + reviewer_bot, + "get_lock_ref_snapshot", + lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), + ) + + assert reviewer_bot.renew_state_issue_lease_lock(context) is False + +def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): + reviewer_bot.ensure_lock_ref_exists() + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): + reviewer_bot.ensure_lock_ref_exists() + +def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read lock commit"): + reviewer_bot.get_lock_ref_snapshot() diff --git a/.github/reviewer-bot-tests/test_lifecycle.py b/.github/reviewer-bot-tests/test_lifecycle.py new file mode 100644 index 000000000..963657bab --- /dev/null +++ b/.github/reviewer-bot-tests/test_lifecycle.py @@ -0,0 +1,434 @@ +import json + +import pytest +from factories import make_state + +from scripts import reviewer_bot + + +def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutation(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_head_sha"] = "head-1" + review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("PR_HEAD_SHA", "head-2") + monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") + + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + + assert reviewer_bot.handle_pull_request_target_synchronize(state) is True + assert review["active_head_sha"] == "head-2" + +def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): + state = make_state(epoch="legacy_v14") + entry = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert entry is not None + entry["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "hello") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "dana"}, + }, + ) + assert reviewer_bot.handle_comment_event(state) is False + +def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" + review["transition_warning_sent"] = "2026-03-10T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + +def test_handle_transition_notice_records_transition_notice_sent_at_once(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True + assert review["transition_notice_sent_at"] is not None + assert reviewer_bot.handle_transition_notice(state, 42, "alice") is False + assert len(posted) == 1 + +def test_handle_transition_notice_message_does_not_claim_reassignment(monkeypatch): + state = make_state() + reviewer_bot.ensure_review_entry(state, 42, create=True) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True + assert "reassigned to the next person in the queue" not in posted[0] + assert "/pass" in posted[0] + +def test_reviewer_comment_clears_warning_and_transition_notice_markers(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["transition_warning_sent"] = "2026-03-10T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "hello") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "dana"}, + }, + ) + assert reviewer_bot.handle_comment_event(state) is True + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + +def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" + review["transition_warning_sent"] = "2026-03-10T00:00:00Z" + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "check_overdue_reviews", + lambda bot, state: [ + { + "issue_number": 42, + "reviewer": "alice", + "days_overdue": 20, + "days_since_warning": 15, + "needs_warning": False, + "needs_transition": True, + } + ], + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + posted = [] + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + + def fake_api(method, endpoint, data=None): + if endpoint == "issues/42/comments?per_page=100": + return [ + { + "id": 99, + "created_at": "2026-03-25T15:22:42Z", + "body": "🔔 **Transition Period Ended**\n\nExisting notice", + "user": {"login": "github-actions[bot]"}, + } + ] + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_api) + assert reviewer_bot.handle_scheduled_check(state) is True + assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" + assert posted == [] + +def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation(monkeypatch): + review_data = { + "active_head_sha": "head-1", + "contributor_revision": {"accepted": None}, + } + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result == reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ) + assert review_data["active_head_sha"] == "head-1" + +def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "skipped_not_found" + assert result.failure_kind == "not_found" + +def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "invalid_live_payload" + assert result.reason == "pull_request_head_unavailable" + +def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "closed", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "skipped_not_open" + +def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): + review_data = { + "active_head_sha": "head-1", + "contributor_revision": {"accepted": None}, + "current_cycle_completion": {"completed": True}, + "current_cycle_write_approval": {"has_write_approval": True}, + "review_completed_at": "2026-03-10T00:00:00Z", + "review_completed_by": "alice", + "review_completion_source": "live_review_rebuild", + } + accepted = [] + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-2"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_channel_event", + lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True, + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.outcome == "changed" + assert result.changed is True + assert review_data["active_head_sha"] == "head-2" + assert accepted[0][0] == "contributor_revision" + assert accepted[0][1]["semantic_key"] == "pull_request_head_observed:42:head-2" + assert review_data["current_cycle_completion"] == {} + assert review_data["current_cycle_write_approval"] == {} + assert review_data["review_completed_at"] is None + +def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monkeypatch): + state = make_state() + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_LABELS", json.dumps(["coding guideline"])) + monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + + with pytest.raises(RuntimeError, match="Unable to determine assignees"): + reviewer_bot.handle_issue_or_pr_opened(state) + +def test_issue_edit_by_author_records_contributor_freshness(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("SENDER_LOGIN", "dana") + monkeypatch.setenv("ISSUE_TITLE", "New title") + monkeypatch.setenv("ISSUE_BODY", "body") + monkeypatch.setenv("ISSUE_CHANGES_TITLE_FROM", "Old title") + monkeypatch.setenv("ISSUE_CHANGES_BODY_FROM", "body") + monkeypatch.setenv("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") + assert reviewer_bot.handle_issue_edited_event(state) is True + accepted = review["contributor_comment"]["accepted"] + assert accepted["semantic_key"].startswith("issues_edit_title:42:") + +def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, + "pulls/42/reviews/11": { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + +def test_maybe_record_head_observation_repair_uses_github_api_fallback_after_system_exit(monkeypatch): + review_data = { + "active_head_sha": "head-0", + "contributor_revision": {"accepted": None}, + } + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_channel_event", + lambda review_data, channel, **kwargs: True, + ) + + result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + + assert result.changed is True + assert result.outcome == "changed" + assert review_data["active_head_sha"] == "head-1" diff --git a/.github/reviewer-bot-tests/test_maintenance.py b/.github/reviewer-bot-tests/test_maintenance.py new file mode 100644 index 000000000..18b6b35b3 --- /dev/null +++ b/.github/reviewer-bot-tests/test_maintenance.py @@ -0,0 +1,154 @@ + +from factories import make_state + +from scripts import reviewer_bot + + +def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + assert reviewer_bot.handle_scheduled_check(state) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted is not None + assert accepted["semantic_key"] == "pull_request_review:10" + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + +def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): + state = make_state() + review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) + review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) + assert review_42 is not None + assert review_43 is not None + review_42["current_reviewer"] = "alice" + review_43["current_reviewer"] = "bob" + overdue_called = [] + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: overdue_called.append(True) or []) + monkeypatch.setattr(reviewer_bot.reviews_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) + + def fake_head_repair(bot, issue_number, review_data): + if issue_number == 42: + return reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ) + review_data["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "stale", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") + + monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert overdue_called == [True] + assert review_42["repair_needed"]["kind"] == "live_read_failure" + assert review_42["repair_needed"]["phase"] == "head_observation_repair" + assert review_42["repair_needed"]["failure_kind"] == "server_error" + assert review_43["repair_needed"] is None + +def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) + monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) + + first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + + assert first is True + assert second is False + assert review["repair_needed"]["recorded_at"] == "2026-03-01T00:00:00Z" + +def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "pull_request_unavailable", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert review["repair_needed"] is None diff --git a/.github/reviewer-bot-tests/test_overdue.py b/.github/reviewer-bot-tests/test_overdue.py new file mode 100644 index 000000000..1473b30b8 --- /dev/null +++ b/.github/reviewer-bot-tests/test_overdue.py @@ -0,0 +1,249 @@ +from datetime import timedelta + +from factories import iso_z, make_state + +from scripts import reviewer_bot + + +def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-02T00:00:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + +def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) + + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + +def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) + + assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False + assert review["transition_warning_sent"] is None + +def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): + now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) + assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) + contributor_comment_at = iso_z( + now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": reviewer_review_at, + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_comment"]["accepted"] = { + "semantic_key": "issue_comment:20", + "timestamp": contributor_comment_at, + "actor": "bob", + "reviewed_head_sha": None, + "source_precedence": 0, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + assert overdue[0]["issue_number"] == 42 + assert overdue[0]["needs_warning"] is True + assert overdue[0]["days_overdue"] == 0 + +def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_changes_after_review(monkeypatch): + now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) + assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) + contributor_revision_at = iso_z( + now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": reviewer_review_at, + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_revision"]["accepted"] = { + "semantic_key": "pull_request_sync:42:head-2", + "timestamp": contributor_revision_at, + "actor": None, + "reviewed_head_sha": "head-2", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + assert overdue[0]["issue_number"] == 42 + assert overdue[0]["needs_warning"] is True + assert overdue[0]["days_overdue"] == 0 + +def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-02T00:00:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_revision"]["accepted"] = { + "semantic_key": "pull_request_head_observed:42:head-1", + "timestamp": "2026-03-12T00:00:00Z", + "actor": None, + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + +def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-02T00:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-20T00:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-21T00:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] diff --git a/.github/reviewer-bot-tests/test_project_board.py b/.github/reviewer-bot-tests/test_project_board.py new file mode 100644 index 000000000..a378c824b --- /dev/null +++ b/.github/reviewer-bot-tests/test_project_board.py @@ -0,0 +1,244 @@ +from pathlib import Path + +import yaml +from factories import make_state, valid_reviewer_board_metadata + +from scripts import reviewer_bot + + +def test_reviewer_board_preflight_validates_manifest(monkeypatch): + monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") + monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") + monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) + + preflight = reviewer_bot.reviewer_board_preflight() + + assert preflight.enabled is True + assert preflight.valid is True + assert preflight.project_id == "PVT_kwDOB" + +def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-20T12:34:56Z" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.classification == "open_tracked_assigned" + assert preview.eligible is True + assert preview.desired is not None + assert preview.desired.review_state == "Awaiting Reviewer" + assert preview.desired.reviewer == "alice" + +def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypatch): + state = make_state() + reviewer_bot.ensure_review_entry(state, 42, create=True) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.classification == "open_tracked_unassigned" + assert preview.desired is not None + assert preview.desired.review_state == "Unassigned" + assert preview.desired.reviewer is None + assert preview.desired.waiting_since is None + assert preview.desired.needs_attention == "No" + +def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "closed", "pull_request": None, "labels": []}, + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.classification == "closed" + assert preview.eligible is False + assert preview.desired is not None + assert preview.desired.archive is True + assert preview.desired.ensure_membership is False + +def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypatch): + state = make_state() + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.classification == "open_untracked" + assert preview.eligible is False + assert preview.desired is not None + assert preview.desired.archive is True + +def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-20T12:34:56Z" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-21T08:00:00Z", + actor="alice", + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-21T08:00:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.desired is not None + assert preview.desired.assigned_at == "2026-03-20" + assert preview.desired.waiting_since == "2026-03-21" + +def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-17T09:00:00Z" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + desired_labels, _ = reviewer_bot.project_status_labels_for_item(42, state) + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert preview.desired is not None + assert preview.desired.review_state == reviewer_bot.REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR + +def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-20T12:34:56Z" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + review["repair_needed"] = { + "kind": "projection_failure", + "reason": "projection_failed", + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + ) + + preview = reviewer_bot.preview_board_projection_for_item(state, 42) + + assert preview.desired is not None + assert ( + preview.desired.needs_attention + == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED + ) + +def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) + on_block = data.get("on", data.get(True)) + workflow_dispatch = on_block["workflow_dispatch"] + action_input = workflow_dispatch["inputs"]["action"] + assert "preview-reviewer-board" in action_input["options"] + issue_number_input = workflow_dispatch["inputs"]["issue_number"] + assert issue_number_input["required"] is False + assert issue_number_input["type"] == "string" + +def test_sweeper_repair_workflow_scopes_reviewer_board_env_to_preview_only(): + workflow_text = Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8") + assert "ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}" in workflow_text + assert ( + "REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }}" + in workflow_text + ) + assert ( + "REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }}" + in workflow_text + ) + +def test_reviewer_board_manifest_includes_projection_repair_attention_option(): + options = reviewer_bot.REVIEWER_BOARD_PROJECT_MANIFEST[ + reviewer_bot.REVIEWER_BOARD_FIELD_NEEDS_ATTENTION + ]["options"] + + assert reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py new file mode 100644 index 000000000..a8dc65009 --- /dev/null +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -0,0 +1,1310 @@ +import json +import os + +import pytest +from factories import make_state + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing, sweeper + + +def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, **kwargs: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + + message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( + reviewer_bot, + state, + 42, + require_pull_request_context=True, + ) + + assert success is True + assert changed is False + assert "no reconciliation transitions applied" in message + +def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "bob" + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/11": + return { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "pull_request_review:11" in review["reconciled_source_events"] + assert "pull_request_review:11" not in review["deferred_gaps"] + +def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:12", + "pr_number": 42, + "review_id": 12, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "pulls/42/reviews/12": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = review["deferred_gaps"]["pull_request_review:12"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" + +def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /queue" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 610, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/210": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "issue_comment:210" in review["reconciled_source_events"] + assert "issue_comment:210" not in review["deferred_gaps"] + +def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + payload_path = tmp_path / "deferred.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "APPROVED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"head": {"sha": "head-2"}}, + "pulls/42/reviews/11": { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "APPROVED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "APPROVED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False + +def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:99", + "pr_number": 42, + "review_id": 99, + "source_submitted_at": "2026-03-17T11:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-0", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/99": + return { + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" + +def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:99", + "pr_number": 42, + "comment_id": 99, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: ( + {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None + ), + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" + +def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "author reply in review thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 701, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:301", + "pr_number": 42, + "comment_id": 301, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + "actor_id": 5, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/301": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["contributor_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:301" + +def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "reviewer reply in thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 702, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:302", + "pr_number": 42, + "comment_id": 302, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T11:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/302": + return { + "body": live_body, + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" + +def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 703, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:303", + "pr_number": 42, + "comment_id": 303, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), + ) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" + assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" + +def test_review_comment_artifact_identity_validation(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 704, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:304", + "pr_number": 42, + "comment_id": 304, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-704-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "704") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None) + assert reviewer_bot.handle_workflow_run_event(state) is True + +def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 601, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:199", + "pr_number": 42, + "comment_id": 199, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "601") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/199": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None + assert os.environ["IS_PULL_REQUEST"] == "true" + assert os.environ["ISSUE_AUTHOR"] == "dana" + assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] + +def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): + state = make_state() + state["queue"] = [{"github": "bob", "name": "Bob"}] + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /claim" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 602, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:200", + "pr_number": 42, + "comment_id": 200, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "602") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + assignment_calls = [] + removed_reviewers = [] + posted_comments = [] + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return { + "user": {"login": "dana"}, + "labels": [{"name": "coding guideline"}], + "requested_reviewers": [{"login": "alice"}], + } + if endpoint == "issues/comments/200": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + def fake_request(issue_number, username): + assignment_calls.append( + { + "issue_number": issue_number, + "username": username, + "is_pull_request": os.environ.get("IS_PULL_REQUEST"), + "issue_author": os.environ.get("ISSUE_AUTHOR"), + } + ) + return reviewer_bot.AssignmentAttempt(success=True, status_code=201) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", fake_request) + monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: removed_reviewers.append((issue_number, username)) or True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted_comments.append((issue_number, body)) or True) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + assert reviewer_bot.handle_workflow_run_event(state) is True + assert assignment_calls == [ + { + "issue_number": 42, + "username": "bob", + "is_pull_request": "true", + "issue_author": "dana", + } + ] + assert removed_reviewers == [(42, "alice")] + assert state["active_reviews"]["42"]["current_reviewer"] == "bob" + assert posted_comments + +def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /claim" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 603, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:201", + "pr_number": 42, + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "issues/comments/201": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + command_calls = [] + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_handle_command", + lambda *args, **kwargs: command_calls.append("called") or True, + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert command_calls == [] + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" + assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] + +def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + payload = { + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_event_key": "issue_comment:201", + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_created_at": "2026-03-17T10:00:00Z", + "pr_number": 42, + "source_run_id": 603, + "source_run_attempt": 1, + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", + } + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + + result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( + reviewer_bot, + review, + payload, + "@guidelines-bot /claim", + ) + + assert result.live_classified is None + assert result.changed is True + assert result.failed_closed is True + assert review["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" + +def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment-unavailable.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 603, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:205", + "pr_number": 42, + "comment_id": 205, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "issues/comments/205": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" + +def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 604, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:202", + "pr_number": 42, + "comment_id": 202, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "604") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "issues/comments/202": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_plus_text", + "has_non_command_text": True, + "command_count": 1, + "command": "claim", + "args": [], + "normalized_body": body, + }, + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" + assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] + +def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", lambda issue_number, actor: ("ok", True)) + assert reviewer_bot.handle_manual_dispatch(state) is True + assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + +def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.delenv("ISSUE_LABELS", raising=False) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + + observed = {} + + def fake_handle(issue_number, actor): + observed["issue_number"] = issue_number + observed["actor"] = actor + observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) + return ("ok", True) + + monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", fake_handle) + assert reviewer_bot.handle_manual_dispatch(state) is True + assert observed == { + "issue_number": 42, + "actor": "alice", + "issue_labels": [reviewer_bot.FLS_AUDIT_LABEL], + } + assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + +def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + called = {"handle": 0} + monkeypatch.setattr( + reviewer_bot, + "handle_accept_no_fls_changes_command", + lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), + ) + assert reviewer_bot.handle_manual_dispatch(state) is True + assert called["handle"] == 0 + pending = review["pending_privileged_commands"]["issue_comment:100"] + assert pending["status"] == "failed_closed" + assert pending["result"] == "live_revalidation_failed" + +def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") + monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): + reviewer_bot.resolve_workflow_run_pr_number() + +def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-25T10:00:00Z"}], + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + {"id": 202, "submitted_at": "2026-03-25T11:00:00Z", "state": "APPROVED"}, + {"id": 303, "submitted_at": "2026-03-25T09:00:00Z", "updated_at": "2026-03-25T12:00:00Z", "state": "DISMISSED"}, + ], + ) + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + gaps = state["active_reviews"]["42"]["deferred_gaps"] + assert "issue_comment:101" in gaps + assert "pull_request_review:202" in gaps + assert "pull_request_review_dismissed:303" in gaps + assert gaps["pull_request_review_dismissed:303"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" + +def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "issues/42/comments?per_page=100&page=1": + return [] + if endpoint == "pulls/42/comments?per_page=100": + return [{"id": 404, "created_at": "2026-03-25T10:30:00Z", "user": {"login": "dana", "type": "User"}}] + if endpoint.startswith("actions/workflows/"): + return {"workflow_runs": []} + return None + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + gaps = state["active_reviews"]["42"]["deferred_gaps"] + assert "pull_request_review_comment:404" in gaps + assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" + +def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["reconciled_source_events"] = ["pull_request_review_dismissed:303"] + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [], + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + {"id": 303, "submitted_at": "2026-03-17T09:00:00Z", "updated_at": "2026-03-17T12:00:00Z", "state": "DISMISSED"}, + ], + ) + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} + +def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["reconciled_source_events"] = ["issue_comment:101", "pull_request_review:202"] + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-17T10:00:00Z"}], + }.get(endpoint), + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}]) + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} + + +@pytest.mark.parametrize( + ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), + [ + ( + {"source_event_name": "issue_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Comment Observer", + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-1-attempt-2", + "deferred-comment.json", + ), + ( + {"source_event_name": "pull_request_review", "source_event_action": "submitted", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Submitted Observer", + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-1-attempt-2", + "deferred-review-submitted.json", + ), + ( + {"source_event_name": "pull_request_review", "source_event_action": "dismissed", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Dismissed Observer", + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-1-attempt-2", + "deferred-review-dismissed.json", + ), + ( + {"source_event_name": "pull_request_review_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, + "Reviewer Bot PR Review Comment Observer", + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-1-attempt-2", + "deferred-review-comment.json", + ), + ], +) +def test_deferred_workflow_identity_helpers_match_expected_contract( + payload, + workflow_name, + workflow_file, + artifact_name, + payload_name, +): + assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( + workflow_name, + workflow_file, + ) + assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name + assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name + +def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + +def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="run_attempt mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + +def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="did not conclude successfully"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py deleted file mode 100644 index 74481b68f..000000000 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ /dev/null @@ -1,5387 +0,0 @@ -import io -import json -import os -import subprocess -import zipfile -from datetime import timedelta -from pathlib import Path -from typing import get_type_hints - -import pytest -import yaml - -from builder import build_cli -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing, sweeper -from scripts.reviewer_bot_lib.context import ReviewerBotContext - - -class FakeGitHubResponse: - def __init__(self, status_code, payload=None, text="", headers=None): - self.status_code = status_code - self._payload = payload - self.text = text - self.headers = headers or {} - self.content = b"" if payload is None and not text else b"x" - - def json(self): - if isinstance(self._payload, Exception): - raise self._payload - return self._payload - - -def make_zip_payload(file_name: str, payload: dict) -> bytes: - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - archive.writestr(file_name, json.dumps(payload)) - return buffer.getvalue() - - -def make_state(epoch: str = "freshness_v15"): - return { - "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, - "freshness_runtime_epoch": epoch, - "last_updated": None, - "current_index": 0, - "queue": [], - "pass_until": [], - "recent_assignments": [], - "active_reviews": {}, - } - - -def valid_reviewer_board_metadata(): - return { - "data": { - "organization": { - "projectV2": { - "id": "PVT_kwDOB", - "title": "Reviewer Board", - "fields": { - "nodes": [ - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-review-state", - "name": "Review State", - "options": [ - {"id": "opt-ar", "name": "Awaiting Reviewer"}, - {"id": "opt-ac", "name": "Awaiting Contributor"}, - {"id": "opt-aw", "name": "Awaiting Write Approval"}, - {"id": "opt-done", "name": "Done"}, - {"id": "opt-unassigned", "name": "Unassigned"}, - ], - }, - { - "__typename": "ProjectV2Field", - "dataType": "TEXT", - "id": "field-reviewer", - "name": "Reviewer", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-assigned-at", - "name": "Assigned At", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-waiting-since", - "name": "Waiting Since", - }, - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-needs-attention", - "name": "Needs Attention", - "options": [ - {"id": "opt-no", "name": "No"}, - {"id": "opt-warning", "name": "Warning Sent"}, - {"id": "opt-notice", "name": "Transition Notice Sent"}, - {"id": "opt-triage", "name": "Triage Approval Required"}, - {"id": "opt-repair", "name": "Projection Repair Required"}, - ], - }, - ] - }, - } - } - } - } - - -def iso_z(dt): - return dt.isoformat().replace("+00:00", "Z") - - -@pytest.fixture(autouse=True) -def clean_env(monkeypatch): - keys = [ - "EVENT_NAME", - "EVENT_ACTION", - "ISSUE_NUMBER", - "ISSUE_AUTHOR", - "IS_PULL_REQUEST", - "COMMENT_BODY", - "COMMENT_AUTHOR", - "COMMENT_ID", - "COMMENT_SOURCE_EVENT_KEY", - "COMMENT_CREATED_AT", - "COMMENT_USER_TYPE", - "COMMENT_AUTHOR_ASSOCIATION", - "COMMENT_SENDER_TYPE", - "COMMENT_INSTALLATION_ID", - "COMMENT_PERFORMED_VIA_GITHUB_APP", - "CURRENT_WORKFLOW_FILE", - "GITHUB_REPOSITORY", - "GITHUB_REF", - "ISSUE_BODY", - "ISSUE_UPDATED_AT", - "ISSUE_CHANGES_TITLE_FROM", - "ISSUE_CHANGES_BODY_FROM", - "SENDER_LOGIN", - "DEFERRED_CONTEXT_PATH", - "DEFERRED_ARTIFACT_RETENTION_DAYS", - "WORKFLOW_RUN_TRIGGERING_NAME", - "WORKFLOW_RUN_TRIGGERING_ID", - "WORKFLOW_RUN_TRIGGERING_ATTEMPT", - "WORKFLOW_RUN_TRIGGERING_CONCLUSION", - "MANUAL_ACTION", - "PRIVILEGED_SOURCE_EVENT_KEY", - ] - for key in keys: - monkeypatch.delenv(key, raising=False) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - - -def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): - monkeypatch.setattr(reviewer_bot, "get_state_issue", lambda: {"body": "queue: []\n"}) - state = reviewer_bot.load_state() - assert state["schema_version"] == reviewer_bot.STATE_SCHEMA_VERSION - assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY - - -def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): - hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) - - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult - - -def test_runtime_context_protocol_exposes_structured_head_repair_contract(): - hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) - - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult - - -def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, **kwargs: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - - message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( - reviewer_bot, - state, - 42, - require_pull_request_context=True, - ) - - assert success is True - assert changed is False - assert "no reconciliation transitions applied" in message - - -def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:100", - timestamp="2026-03-17T09:00:00Z", - actor="alice", - ) - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") - - changed = reviewer_bot.comment_routing_module._record_conversation_freshness( - reviewer_bot, - state, - 42, - "alice", - 100, - "2026-03-17T10:00:00Z", - ) - - assert changed is True - assert review["last_reviewer_activity"] == "2026-03-17T10:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutation(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_head_sha"] = "head-1" - review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("PR_HEAD_SHA", "head-2") - monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") - - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) - - assert reviewer_bot.handle_pull_request_target_synchronize(state) is True - assert review["active_head_sha"] == "head-2" - - -def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "bob" - review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/11": - return { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert "pull_request_review:11" in review["reconciled_source_events"] - assert "pull_request_review:11" not in review["deferred_gaps"] - - -def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:12", - "pr_number": 42, - "review_id": 12, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "pulls/42/reviews/12": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - - assert reviewer_bot.handle_workflow_run_event(state) is True - gap = review["deferred_gaps"]["pull_request_review:12"] - assert gap["reason"] == "reconcile_failed_closed" - assert gap["failure_kind"] == "server_error" - - -def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /queue" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 610, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:210", - "pr_number": 42, - "comment_id": 210, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/210": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert "issue_comment:210" in review["reconciled_source_events"] - assert "issue_comment:210" not in review["deferred_gaps"] - - -def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot.reviews_module, - "accept_reviewer_review_from_live_review", - lambda review_data, live_review, actor=None: False, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, actor=None: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) - - changed = sweeper._repair_visible_review_gap( - reviewer_bot, - review, - 42, - "pull_request_review:303", - { - "id": 303, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - ) - - assert changed is True - assert "pull_request_review:303" in review["reconciled_source_events"] - assert "pull_request_review:303" not in review["deferred_gaps"] - - -def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["retry_policy"] = kwargs.get("retry_policy") - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, - headers={"etag": '"abc"'}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - snapshot = reviewer_bot.get_state_issue_snapshot() - - assert snapshot is not None - assert snapshot.etag == '"abc"' - assert observed["retry_policy"] == "idempotent_read" - - -def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"body": data["body"]}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') - - assert observed["extra_headers"] == {"If-Match": '"etag-1"'} - - -def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - reviewer_bot.conditional_patch_state_issue("updated", None) - - assert observed["extra_headers"] is None - - -def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): - observed = [] - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed.append((endpoint, kwargs.get("retry_policy"))) - if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: - return reviewer_bot.GitHubApiResult( - status_code=404, - payload={"message": "missing"}, - headers={}, - text="missing", - ok=False, - failure_kind="not_found", - retry_attempts=0, - transport_error=None, - ) - if endpoint == "git/ref/heads/reviewer-bot-state-lock": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"object": {"sha": "base-sha"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "git/ref/heads/main": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"object": {"sha": "base-sha"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ) - if endpoint == "git/refs": - return reviewer_bot.GitHubApiResult( - status_code=201, - payload={"ref": "refs/heads/reviewer-bot-state-lock"}, - headers={}, - text="created", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") - - assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" - assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") - assert observed[1] == ("git/ref/heads/main", "idempotent_read") - - -def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): - responses = iter( - [ - reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), - reviewer_bot.GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") - - with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): - reviewer_bot.ensure_lock_ref_exists() - - -def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"message": "missing tree"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="missing tree SHA"): - reviewer_bot.get_lock_ref_snapshot() - - -def test_save_state_retries_precondition_failed_conflict(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - snapshot = reviewer_bot.StateIssueSnapshot( - body="body", - etag='"etag"', - html_url="https://example.com/state/1", - ) - responses = iter( - [ - reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), - reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) - monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) - monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) - monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) - monkeypatch.setattr( - reviewer_bot, - "render_state_issue_body", - lambda state_obj, lock_meta, base_body: "updated", - ) - monkeypatch.setattr( - reviewer_bot, - "conditional_patch_state_issue", - lambda body, etag=None: next(responses), - ) - monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) - - assert reviewer_bot.save_state(state) is True - - -def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="expected-token", - lock_owner_run_id="run-1", - lock_owner_workflow="wf", - lock_owner_job="job", - state_issue_url="https://example.com/state/1", - ) - monkeypatch.setattr( - reviewer_bot, - "get_lock_ref_snapshot", - lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), - ) - - assert reviewer_bot.renew_state_issue_lease_lock(context) is False - - -def test_reviewer_board_preflight_validates_manifest(monkeypatch): - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") - monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) - - preflight = reviewer_bot.reviewer_board_preflight() - - assert preflight.enabled is True - assert preflight.valid is True - assert preflight.project_id == "PVT_kwDOB" - - -def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( - [ - FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"ok": True} - assert result.retry_attempts == 1 - assert result.failure_kind is None - - -def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - if calls["count"] == 1: - raise reviewer_bot.github_api_module.requests.RequestException("timeout") - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"ok": True} - assert result.retry_attempts == 1 - - -def test_github_api_request_classifies_not_found_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(404, {"message": "missing"}, "missing") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) - - assert result.ok is False - assert result.failure_kind == "not_found" - assert result.retry_attempts == 0 - assert calls["count"] == 1 - - -def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) - - assert result.ok is False - assert result.failure_kind == "forbidden" - assert result.retry_attempts == 0 - assert calls["count"] == 1 - - -def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - responses = iter( - [ - FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"data": {"viewer": {"login": "bot"}}} - assert result.retry_attempts == 1 - - -def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( - [ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.retry_attempts == 1 - - -def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "rate_limited" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - - -def test_github_graphql_request_reports_invalid_payload(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - - -def test_github_graphql_request_reports_graphql_errors(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse( - 200, {"errors": [{"message": "boom"}]}, "boom" - ), - ) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - - -def test_github_api_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - observed = {} - - def fake_request(method, url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) - - assert result.ok is True - assert observed["timeout"] == 12.5 - - -def test_github_api_request_rejects_idempotent_retry_for_non_get(): - with pytest.raises(ValueError, match="only valid for REST GET"): - reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") - - -def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): - with pytest.raises(ValueError, match="only valid for GraphQL queries"): - reviewer_bot.github_graphql_request( - "mutation { closeIssue(input: {}) { clientMutationId } }", - retry_policy="idempotent_read", - ) - - -@pytest.mark.parametrize( - ("env", "expected"), - [ - ({"COMMENT_USER_TYPE": "Bot", "COMMENT_AUTHOR": "dependabot[bot]"}, "bot_account"), - ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice", "COMMENT_INSTALLATION_ID": "7"}, "github_app_or_other_automation"), - ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice"}, "repo_user_principal"), - ({"COMMENT_AUTHOR": "mystery"}, "unknown_actor"), - ], -) -def test_classify_issue_comment_actor(monkeypatch, env, expected): - for key, value in env.items(): - monkeypatch.setenv(key, value) - assert comment_routing.classify_issue_comment_actor() == expected - - -def test_classify_comment_payload_distinguishes_command_plus_text(): - payload = comment_routing.classify_comment_payload(reviewer_bot, "hello\n@guidelines-bot /queue") - assert payload["comment_class"] == "command_plus_text" - assert payload["has_non_command_text"] is True - - -def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, - ) - assert comment_routing.route_issue_comment_trust(reviewer_bot, 42) == "pr_trusted_direct" - - -def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, - ) - with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): - comment_routing.route_issue_comment_trust(reviewer_bot, 42) - - -def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): - state = make_state() - entry = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert entry is not None - entry["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - assert reviewer_bot.handle_comment_event(state) is True - pending = state["active_reviews"]["42"]["pending_privileged_commands"] - assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" - assert pending["issue_comment:100"]["authorization"]["authorized"] is True - - -def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypatch): - state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - assert reviewer_bot.handle_comment_event(state) is False - assert state["active_reviews"] == {} - - -def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): - state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") - called = {"post_comment": 0} - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: called.__setitem__("post_comment", called["post_comment"] + 1) or True) - assert reviewer_bot.handle_comment_event(state) is False - assert state["active_reviews"] == {} - assert called["post_comment"] == 0 - - -def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - assert reviewer_bot.handle_comment_event(state) is True - assert "42" not in state["active_reviews"] - - -def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): - state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - - assert reviewer_bot.handle_comment_event(state) is False - assert state["active_reviews"] == {} - - -def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "open") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: contributor plain text comment") - assert reviewer_bot.handle_comment_event(state) is True - accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] - assert accepted["semantic_key"] == "issue_comment:100" - - -def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda *args, **kwargs: pytest.fail( - "status sync should run only from app orchestration after save" - ), - ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) - assert reviewer_bot.handle_comment_event(state) is True - assert review["review_completion_source"] == "issue_label: sign-off: create pr" - assert review["current_cycle_completion"]["completed"] is True - assert posted == [(42, "✅ Added label `sign-off: create pr`")] - - -def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, - ) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command")) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - assert reviewer_bot.handle_comment_event(state) is False - assert review["review_completion_source"] is None - - -def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): - state = make_state(epoch="legacy_v14") - entry = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert entry is not None - entry["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "hello") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, - ) - assert reviewer_bot.handle_comment_event(state) is False - - -def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - review["transition_warning_sent"] = "2026-03-10T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - - -def test_handle_transition_notice_records_transition_notice_sent_at_once(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True - assert review["transition_notice_sent_at"] is not None - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is False - assert len(posted) == 1 - - -def test_handle_transition_notice_message_does_not_claim_reassignment(monkeypatch): - state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True - assert "reassigned to the next person in the queue" not in posted[0] - assert "/pass" in posted[0] - - -def test_reviewer_comment_clears_warning_and_transition_notice_markers(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["transition_warning_sent"] = "2026-03-10T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "hello") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, - ) - assert reviewer_bot.handle_comment_event(state) is True - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - review["transition_warning_sent"] = "2026-03-10T00:00:00Z" - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "check_overdue_reviews", - lambda bot, state: [ - { - "issue_number": 42, - "reviewer": "alice", - "days_overdue": 20, - "days_since_warning": 15, - "needs_warning": False, - "needs_transition": True, - } - ], - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - - def fake_api(method, endpoint, data=None): - if endpoint == "issues/42/comments?per_page=100": - return [ - { - "id": 99, - "created_at": "2026-03-25T15:22:42Z", - "body": "🔔 **Transition Period Ended**\n\nExisting notice", - "user": {"login": "github-actions[bot]"}, - } - ] - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_api) - assert reviewer_bot.handle_scheduled_check(state) is True - assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" - assert posted == [] - - -def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - assert reviewer_bot.handle_scheduled_check(state) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted is not None - assert accepted["semantic_key"] == "pull_request_review:10" - assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" - - -def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation(monkeypatch): - review_data = { - "active_head_sha": "head-1", - "contributor_revision": {"accepted": None}, - } - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result == reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="skipped_unavailable", - failure_kind="server_error", - reason="pull_request_unavailable", - ) - assert review_data["active_head_sha"] == "head-1" - - -def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): - review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=404, - payload={"message": "missing"}, - headers={}, - text="missing", - ok=False, - failure_kind="not_found", - retry_attempts=0, - transport_error=None, - ), - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result.outcome == "skipped_not_found" - assert result.failure_kind == "not_found" - - -def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatch): - review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result.outcome == "invalid_live_payload" - assert result.reason == "pull_request_head_unavailable" - - -def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): - review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "closed", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result.outcome == "skipped_not_open" - - -def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): - review_data = { - "active_head_sha": "head-1", - "contributor_revision": {"accepted": None}, - "current_cycle_completion": {"completed": True}, - "current_cycle_write_approval": {"has_write_approval": True}, - "review_completed_at": "2026-03-10T00:00:00Z", - "review_completed_by": "alice", - "review_completion_source": "live_review_rebuild", - } - accepted = [] - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-2"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "accept_channel_event", - lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True, - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result.outcome == "changed" - assert result.changed is True - assert review_data["active_head_sha"] == "head-2" - assert accepted[0][0] == "contributor_revision" - assert accepted[0][1]["semantic_key"] == "pull_request_head_observed:42:head-2" - assert review_data["current_cycle_completion"] == {} - assert review_data["current_cycle_write_approval"] == {} - assert review_data["review_completed_at"] is None - - -def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): - state = make_state() - review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) - review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) - assert review_42 is not None - assert review_43 is not None - review_42["current_reviewer"] = "alice" - review_43["current_reviewer"] = "bob" - overdue_called = [] - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: overdue_called.append(True) or []) - monkeypatch.setattr(reviewer_bot.reviews_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) - - def fake_head_repair(bot, issue_number, review_data): - if issue_number == 42: - return reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="skipped_unavailable", - failure_kind="server_error", - reason="pull_request_unavailable", - ) - review_data["repair_needed"] = { - "kind": "live_read_failure", - "phase": "head_observation_repair", - "reason": "stale", - "failure_kind": "server_error", - "recorded_at": "2026-03-01T00:00:00Z", - } - return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") - - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) - - assert reviewer_bot.handle_scheduled_check(state) is True - assert overdue_called == [True] - assert review_42["repair_needed"]["kind"] == "live_read_failure" - assert review_42["repair_needed"]["phase"] == "head_observation_repair" - assert review_42["repair_needed"]["failure_kind"] == "server_error" - assert review_43["repair_needed"] is None - - -def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) - monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) - - first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, - review, - phase="head_observation_repair", - reason="pull_request_unavailable", - failure_kind="server_error", - ) - second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, - review, - phase="head_observation_repair", - reason="pull_request_unavailable", - failure_kind="server_error", - ) - - assert first is True - assert second is False - assert review["repair_needed"]["recorded_at"] == "2026-03-01T00:00:00Z" - - -def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["repair_needed"] = { - "kind": "live_read_failure", - "phase": "head_observation_repair", - "reason": "pull_request_unavailable", - "failure_kind": "server_error", - "recorded_at": "2026-03-01T00:00:00Z", - } - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) - - assert reviewer_bot.handle_scheduled_check(state) is True - assert review["repair_needed"] is None - - -def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - - -def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) - - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - - -def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) - - assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False - assert review["transition_warning_sent"] is None - - -def test_remove_label_reports_failure(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=500, - payload={"message": "boom"}, - headers={}, - text="boom", - ok=False, - failure_kind="server_error", - retry_attempts=0, - transport_error=None, - ), - ) - - assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False - - -def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" - assert reviewer_bot.check_user_permission("alice", "triage") is None - - -def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.get_issue_assignees(42) is None - - -def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_LABELS", json.dumps(["coding guideline"])) - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - with pytest.raises(RuntimeError, match="Unable to determine assignees"): - reviewer_bot.handle_issue_or_pr_opened(state) - - -def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_comment_at = iso_z( - now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) - ) - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_comment"]["accepted"] = { - "semantic_key": "issue_comment:20", - "timestamp": contributor_comment_at, - "actor": "bob", - "reviewed_head_sha": None, - "source_precedence": 0, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) - assert overdue[0]["issue_number"] == 42 - assert overdue[0]["needs_warning"] is True - assert overdue[0]["days_overdue"] == 0 - - -def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_changes_after_review(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_revision_at = iso_z( - now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) - ) - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_sync:42:head-2", - "timestamp": contributor_revision_at, - "actor": None, - "reviewed_head_sha": "head-2", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) - assert overdue[0]["issue_number"] == 42 - assert overdue[0]["needs_warning"] is True - assert overdue[0]["days_overdue"] == 0 - - -def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_head_observed:42:head-1", - "timestamp": "2026-03-12T00:00:00Z", - "actor": None, - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - - -def test_issue_edit_by_author_records_contributor_freshness(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("SENDER_LOGIN", "dana") - monkeypatch.setenv("ISSUE_TITLE", "New title") - monkeypatch.setenv("ISSUE_BODY", "body") - monkeypatch.setenv("ISSUE_CHANGES_TITLE_FROM", "Old title") - monkeypatch.setenv("ISSUE_CHANGES_BODY_FROM", "body") - monkeypatch.setenv("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") - assert reviewer_bot.handle_issue_edited_event(state) is True - accepted = review["contributor_comment"]["accepted"] - assert accepted["semantic_key"].startswith("issues_edit_title:42:") - - -def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "review_head_stale" - - -def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.classification == "open_tracked_assigned" - assert preview.eligible is True - assert preview.desired is not None - assert preview.desired.review_state == "Awaiting Reviewer" - assert preview.desired.reviewer == "alice" - - -def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypatch): - state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.classification == "open_tracked_unassigned" - assert preview.desired is not None - assert preview.desired.review_state == "Unassigned" - assert preview.desired.reviewer is None - assert preview.desired.waiting_since is None - assert preview.desired.needs_attention == "No" - - -def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "closed", "pull_request": None, "labels": []}, - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.classification == "closed" - assert preview.eligible is False - assert preview.desired is not None - assert preview.desired.archive is True - assert preview.desired.ensure_membership is False - - -def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypatch): - state = make_state() - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.classification == "open_untracked" - assert preview.eligible is False - assert preview.desired is not None - assert preview.desired.archive is True - - -def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-21T08:00:00Z", - actor="alice", - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-21T08:00:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.desired is not None - assert preview.desired.assigned_at == "2026-03-20" - assert preview.desired.waiting_since == "2026-03-21" - - -def test_project_status_labels_uses_live_review_fallback_for_stale_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "review_head_stale" - - -def test_project_status_labels_prefers_current_head_review_over_newer_stale_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 11, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:01:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_contributor_response" - assert response_state["reason"] == "completion_missing" - assert response_state["reviewer_review"]["semantic_key"] == "pull_request_review:10" - assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" - - -def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-02T00:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-20T00:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-21T00:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - - -def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_revision", - semantic_key="pull_request_sync:42:head-1", - timestamp="2026-03-17T12:00:00Z", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_reviewer_response" - assert response_state["reason"] == "contributor_revision_newer" - - -def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-17T09:00:00Z" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - - desired_labels, _ = reviewer_bot.project_status_labels_for_item(42, state) - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert preview.desired is not None - assert preview.desired.review_state == reviewer_bot.REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR - - -def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - review["repair_needed"] = { - "kind": "projection_failure", - "reason": "projection_failed", - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) - - preview = reviewer_bot.preview_board_projection_for_item(state, 42) - - assert preview.desired is not None - assert ( - preview.desired.needs_attention - == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED - ) - - -def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "vccjgust" - review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-02-18T09:30:00Z", - actor="dana", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 30, - "state": "COMMENTED", - "submitted_at": "2026-02-18T10:00:00Z", - "commit_id": "head-older", - "user": {"login": "vccjgust"}, - }, - { - "id": 31, - "state": "COMMENTED", - "submitted_at": "2026-02-18T11:00:00Z", - "commit_id": "head-current", - "user": {"login": "vccjgust"}, - }, - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-03-17T10:05:00Z", - actor="bob", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "contributor_comment_newer" - - -def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["last_reviewer_activity"] = "2026-03-20T10:00:00Z" - review["transition_warning_sent"] = "2026-03-21T10:00:00Z" - review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" - reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") - assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "bob"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} - assert metadata["state"] == "awaiting_write_approval" - review["mandatory_approver_required"] = True - desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} - - -def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "pull_request_unavailable" - - -def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid( - monkeypatch, -): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {}}, - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "pull_request_head_unavailable" - - -def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "permission_unavailable" - - -def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="server_error"): - reviewer_bot.list_open_items_with_status_labels() - - -def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - payload_path = tmp_path / "deferred.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "APPROVED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False - - -def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" - assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] - - -def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change( - monkeypatch, -): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["reviewer_review"] = { - "accepted": { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-17T10:01:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - }, - "seen_keys": ["pull_request_review:10"], - } - review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( - reviewer_bot, - 42, - review, - ) - - assert changed is True - assert preferred_review is not None - assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_reviewer_response" - assert review == before - - -def test_compute_pr_approval_state_result_is_pure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - - result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) - - assert result["ok"] is True - assert result["completion"]["completed"] is True - assert review == before - - -def test_apply_pr_approval_state_mutates_expected_fields(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - - reviewer_bot.reviews_module.apply_pr_approval_state( - review, - completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, - write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, - current_head_sha="head-1", - ) - - assert review["active_head_sha"] == "head-1" - assert review["current_cycle_completion"]["completed"] is True - assert review["current_cycle_write_approval"]["has_write_approval"] is True - assert review["review_completion_source"] == "live_review_rebuild" - - -def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:99", - "pr_number": 42, - "review_id": 99, - "source_submitted_at": "2026-03-17T11:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-0", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/99": - return { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" - - -def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:99", - "pr_number": 42, - "comment_id": 99, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ( - {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None - ), - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" - - -def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - live_body = "author reply in review thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 701, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:301", - "pr_number": 42, - "comment_id": 301, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - "actor_id": 5, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/301": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["contributor_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:301" - - -def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - live_body = "reviewer reply in thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 702, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:302", - "pr_number": 42, - "comment_id": 302, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T11:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/302": - return { - "body": live_body, - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" - - -def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 703, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:303", - "pr_number": 42, - "comment_id": 303, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" - assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" - - -def test_review_comment_artifact_identity_validation(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 704, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:304", - "pr_number": 42, - "comment_id": 304, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-704-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "704") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None) - assert reviewer_bot.handle_workflow_run_event(state) is True - - -def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 601, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:199", - "pr_number": 42, - "comment_id": 199, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "601") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/199": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" - assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None - assert os.environ["IS_PULL_REQUEST"] == "true" - assert os.environ["ISSUE_AUTHOR"] == "dana" - assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] - - -def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): - state = make_state() - state["queue"] = [{"github": "bob", "name": "Bob"}] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 602, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:200", - "pr_number": 42, - "comment_id": 200, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "602") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - assignment_calls = [] - removed_reviewers = [] - posted_comments = [] - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return { - "user": {"login": "dana"}, - "labels": [{"name": "coding guideline"}], - "requested_reviewers": [{"login": "alice"}], - } - if endpoint == "issues/comments/200": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - def fake_request(issue_number, username): - assignment_calls.append( - { - "issue_number": issue_number, - "username": username, - "is_pull_request": os.environ.get("IS_PULL_REQUEST"), - "issue_author": os.environ.get("ISSUE_AUTHOR"), - } - ) - return reviewer_bot.AssignmentAttempt(success=True, status_code=201) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", fake_request) - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: removed_reviewers.append((issue_number, username)) or True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted_comments.append((issue_number, body)) or True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert assignment_calls == [ - { - "issue_number": 42, - "username": "bob", - "is_pull_request": "true", - "issue_author": "dana", - } - ] - assert removed_reviewers == [(42, "alice")] - assert state["active_reviews"]["42"]["current_reviewer"] == "bob" - assert posted_comments - - -def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:201", - "pr_number": 42, - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/201": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_only", - "has_non_command_text": False, - "command_count": 2, - "command": None, - "args": [], - "normalized_body": body, - }, - ) - command_calls = [] - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_handle_command", - lambda *args, **kwargs: command_calls.append("called") or True, - ) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert command_calls == [] - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" - assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] - - -def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - payload = { - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_event_key": "issue_comment:201", - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_created_at": "2026-03-17T10:00:00Z", - "pr_number": 42, - "source_run_id": 603, - "source_run_attempt": 1, - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", - } - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_only", - "has_non_command_text": False, - "command_count": 2, - "command": None, - "args": [], - "normalized_body": body, - }, - ) - - result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( - reviewer_bot, - review, - payload, - "@guidelines-bot /claim", - ) - - assert result.live_classified is None - assert result.changed is True - assert result.failed_closed is True - assert review["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" - - -def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment-unavailable.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:205", - "pr_number": 42, - "comment_id": 205, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "issues/comments/205": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - - assert reviewer_bot.handle_workflow_run_event(state) is True - gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] - assert gap["reason"] == "reconcile_failed_closed" - assert gap["failure_kind"] == "server_error" - - -def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 604, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:202", - "pr_number": 42, - "comment_id": 202, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "604") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/202": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_plus_text", - "has_non_command_text": True, - "command_count": 1, - "command": "claim", - "args": [], - "normalized_body": body, - }, - ) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" - assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] - - -def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): - state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) - payload_path = tmp_path / "observer-noop.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 1, - "kind": "observer_noop", - "reason": "ignored_non_human_automation", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 777, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:111", - "pr_number": 42, - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "777") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - assert reviewer_bot.handle_workflow_run_event(state) is False - assert state["active_reviews"]["42"]["deferred_gaps"] == {} - - -def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", lambda issue_number, actor: ("ok", True)) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - - -def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.delenv("ISSUE_LABELS", raising=False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - - observed = {} - - def fake_handle(issue_number, actor): - observed["issue_number"] = issue_number - observed["actor"] = actor - observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) - return ("ok", True) - - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", fake_handle) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert observed == { - "issue_number": 42, - "actor": "alice", - "issue_labels": [reviewer_bot.FLS_AUDIT_LABEL], - } - assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - - -def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - called = {"handle": 0} - monkeypatch.setattr( - reviewer_bot, - "handle_accept_no_fls_changes_command", - lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), - ) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert called["handle"] == 0 - pending = review["pending_privileged_commands"]["issue_comment:100"] - assert pending["status"] == "failed_closed" - assert pending["result"] == "live_revalidation_failed" - - -def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): - called = {"post": 0} - monkeypatch.setattr(reviewer_bot, "find_open_pr_for_branch_status", lambda branch: ("unavailable", None)) - monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None) - - with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): - reviewer_bot.create_pull_request("feature-branch", "main", 42) - - assert called["post"] == 0 - - -def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"not": "a list"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) - - -def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) - - -def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") - monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): - reviewer_bot.resolve_workflow_run_pr_number() - - -def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - state["queue"] = [{"github": "felix91gr", "name": "F\u00e9lix Fischer"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_assign_command_posts_pr_guidance_on_success(monkeypatch): - state = make_state() - state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") - assert success is True - assert response == "✅ @felix91gr has been assigned as reviewer." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] - - -def test_claim_command_posts_pr_guidance_on_success(monkeypatch): - state = make_state() - state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - response, success = reviewer_bot.handle_claim_command(state, 42, "felix91gr") - assert success is True - assert response == "✅ @felix91gr has claimed this review." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] - - -def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): - state = make_state() - state["queue"] = [ - {"github": "alice", "name": "Alice"}, - {"github": "felix91gr", "name": "Félix Fischer"}, - ] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: ["alice"]) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: True) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) - assert success is True - assert "@felix91gr is now assigned as the reviewer." in response - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] - - -def test_assign_from_queue_posts_guidance_only_once(monkeypatch): - state = make_state() - state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) - assert success is True - assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] - - -def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): - commands_seen = [] - - def fake_run_command(command, cwd, check=True): - commands_seen.append(command) - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - raise AssertionError(f"Unexpected command: {command}") - - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] - assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] - - -def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): - def fake_run_command(command, cwd, check=True): - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="README.md\nsrc/spec.lock\n", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="src/spec.lock\n", stderr="") - raise AssertionError(f"Unexpected command: {command}") - - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] - - -def test_privileged_commands_workflow_executes_source_entrypoint(): - workflow_text = Path(".github/workflows/reviewer-bot-privileged-commands.yml").read_text(encoding="utf-8") - assert "Fetch trusted bot source tarball" in workflow_text - assert 'REVIEWER_BOT_TARGET_REPO_ROOT: ${{ github.workspace }}' in workflow_text - assert 'run: uv run --project "$BOT_SRC_ROOT" python "$BOT_SRC_ROOT/scripts/reviewer_bot.py"' in workflow_text - - -def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - observed = {"cwd": None} - - def fake_list_changed_files(repo_root): - observed["cwd"] = repo_root - return ["README.md"] - - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) - assert observed["cwd"] == tmp_path - - -def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - list_calls = {"count": 0} - - def fake_list_changed_files(repo_root): - list_calls["count"] += 1 - assert repo_root == tmp_path - return [] - - commands = [] - - def fake_run_command(command, cwd, check=False): - commands.append((command, cwd, check)) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) - assert list_calls["count"] == 2 - assert commands == [ - (["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], tmp_path, False), - (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), - ] - - -def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) - - def fake_run_command(command, cwd, check=False): - return subprocess.CompletedProcess( - command, - 1, - stdout="", - stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", - ) - - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert success is False - assert "Audit command failed." in message - assert "--locked was provided" in message - - -def test_update_spec_lock_file_mode_exits_before_build_docs(monkeypatch, tmp_path): - monkeypatch.setattr(build_cli.argparse.ArgumentParser, "parse_args", lambda self: type("Args", (), { - "clear": False, - "offline": False, - "ignore_spec_lock_diff": False, - "update_spec_lock_file": True, - "validate_urls": False, - "serve": False, - "check_links": False, - "xml": False, - "verbose": False, - "debug": False, - })()) - called = {"update": 0, "build": 0} - monkeypatch.setattr(build_cli, "update_spec_lockfile", lambda url, path: called.__setitem__("update", called["update"] + 1) or True) - monkeypatch.setattr(build_cli, "build_docs", lambda *args, **kwargs: called.__setitem__("build", called["build"] + 1)) - with pytest.raises(SystemExit) as exc_info: - build_cli.main(tmp_path) - assert exc_info.value.code == 0 - assert called == {"update": 1, "build": 0} - - -def test_observer_run_reason_mapping_and_near_miss_signature(): - signature = {"status": "waiting", "conclusion": None, "name": "approval_pending"} - assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "approval_pending"}, signature) == "awaiting_observer_approval" - assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "almost"}, signature) == "observer_state_unknown" - - -def test_negative_missing_run_requires_full_scan_and_recheck(): - gap = { - "source_event_created_at": "2026-03-15T00:00:00Z", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run_found": False, - "approval_pending_evidence_retained": False, - } - assert sweeper.can_mark_observer_run_missing(gap) is True - gap["later_recheck_complete"] = False - assert sweeper.can_mark_observer_run_missing(gap) is False - - -def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_window(): - os.environ["GITHUB_REPOSITORY"] = "rustfoundation/safety-critical-rust-coding-guidelines" - result = sweeper.correlate_candidate_observer_runs( - "issue_comment:101", - source_event_kind="issue_comment:created", - source_event_created_at="2026-03-17T10:00:00Z", - pr_number=42, - workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", - workflow_runs=[ - { - "id": 1, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:05:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, - { - "id": 2, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:40:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, - ], - ) - assert result["candidate_run_ids"] == [1] - - -def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): - result = sweeper.correlate_run_artifacts_exact( - { - 10: [{"source_event_key": "issue_comment:101", "source_run_id": 10, "source_run_attempt": 1, "pr_number": 42}], - 11: [{"source_event_key": "issue_comment:101", "source_run_id": 11, "source_run_attempt": 1, "pr_number": 42}], - }, - "issue_comment:101", - pr_number=42, - ) - assert result["status"] == "observer_state_unknown" - assert result["reason"] == "ambiguous_exact_artifact_matches" - - -def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"artifacts": [{"id": 1, "name": "artifact"}]}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ), - ) - - assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] - - -def test_evaluate_gap_state_treats_artifact_download_unavailable_as_unknown(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "download_unavailable"}}, - ) - assert reason == "observer_state_unknown" - assert diagnostic == "artifact_download_unavailable" - - -def test_evaluate_gap_state_only_emits_missing_after_negative_inference_contract(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - { - "source_event_created_at": "2026-03-15T00:00:00Z", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run_found": False, - "approval_pending_evidence_retained": False, - }, - { - "status": "no_candidate_runs", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run": None, - }, - None, - None, - ) - assert reason == "observer_run_missing" - assert diagnostic == "negative_inference_satisfied" - - -def test_evaluate_gap_state_completed_success_without_exact_artifact_is_artifact_missing(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "reason": "no_exact_source_event_key_match"}, - ) - assert reason == "artifact_missing" - assert diagnostic == "no_exact_source_event_key_match" - - -def test_evaluate_gap_state_completed_success_with_expired_artifact_marks_artifact_expired(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "expired"}}, - ) - assert reason == "artifact_expired" - assert diagnostic == "prior_visibility_or_retention_proof_required" - - -def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention(): - expired = { - "artifact_seen_at": "2026-03-10T00:00:00Z", - "run_created_at": "2026-03-10T00:00:00Z", - } - assert sweeper.classify_artifact_gap_reason(expired) == "artifact_expired" - missing = { - "artifact_inspection_complete": True, - "run_created_at": "2026-03-17T00:00:00Z", - } - assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" - - -def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: [ - { - "id": 100, - "created_at": "2026-03-25T10:00:00Z", - "user": {"login": "github-actions[bot]", "type": "Bot"}, - }, - { - "id": 101, - "created_at": "2026-03-25T11:00:00Z", - "user": {"login": "alice", "type": "User"}, - }, - ], - ) - discovered, complete = sweeper._discover_visible_comment_events(reviewer_bot, 42, review) - assert complete is True - assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] - - -def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-25T10:00:00Z"}], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - {"id": 202, "submitted_at": "2026-03-25T11:00:00Z", "state": "APPROVED"}, - {"id": 303, "submitted_at": "2026-03-25T09:00:00Z", "updated_at": "2026-03-25T12:00:00Z", "state": "DISMISSED"}, - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - gaps = state["active_reviews"]["42"]["deferred_gaps"] - assert "issue_comment:101" in gaps - assert "pull_request_review:202" in gaps - assert "pull_request_review_dismissed:303" in gaps - assert gaps["pull_request_review_dismissed:303"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" - - -def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "issues/42/comments?per_page=100&page=1": - return [] - if endpoint == "pulls/42/comments?per_page=100": - return [{"id": 404, "created_at": "2026-03-25T10:30:00Z", "user": {"login": "dana", "type": "User"}}] - if endpoint.startswith("actions/workflows/"): - return {"workflow_runs": []} - return None - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - gaps = state["active_reviews"]["42"]["deferred_gaps"] - assert "pull_request_review_comment:404" in gaps - assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" - - -def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["reconciled_source_events"] = ["pull_request_review_dismissed:303"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - {"id": 303, "submitted_at": "2026-03-17T09:00:00Z", "updated_at": "2026-03-17T12:00:00Z", "state": "DISMISSED"}, - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False - assert state["active_reviews"]["42"]["deferred_gaps"] == {} - - -def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["reconciled_source_events"] = ["issue_comment:101", "pull_request_review:202"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-17T10:00:00Z"}], - }.get(endpoint), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}]) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False - assert state["active_reviews"]["42"]["deferred_gaps"] == {} - - -def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): - run_correlation = { - "candidate_run_ids": [123], - "correlated_run": None, - "correlated_run_found": False, - } - monkeypatch.setattr(sweeper, "_fetch_run_detail", lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}) - detail = sweeper._maybe_fetch_single_candidate_run_detail(reviewer_bot, run_correlation, {"status": "no_exact_artifact_match"}) - assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} - assert run_correlation["correlated_run"] == 123 - - -def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else {"workflow_runs": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 202, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - assert "pull_request_review:202" not in review["deferred_gaps"] - assert "pull_request_review:202" in review["reconciled_source_events"] - - -def test_workflow_policy_split_and_lock_only_boundaries(): - workflows_dir = Path(".github/workflows") - required = { - "reviewer-bot-issues.yml", - "reviewer-bot-issue-comment-direct.yml", - "reviewer-bot-sweeper-repair.yml", - "reviewer-bot-pr-metadata.yml", - "reviewer-bot-pr-comment-trusted.yml", - "reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-reconcile.yml", - "reviewer-bot-privileged-commands.yml", - } - assert required.issubset({path.name for path in workflows_dir.glob("reviewer-bot-*.yml")}) - for path in required: - data = yaml.safe_load((workflows_dir / path).read_text(encoding="utf-8")) - jobs = data.get("jobs", {}) - for job in jobs.values(): - permissions = job.get("permissions", {}) - steps = job.get("steps", []) - uses_values = [step.get("uses", "") for step in steps if isinstance(step, dict)] - text = (workflows_dir / path).read_text(encoding="utf-8") - if "observer" in path: - assert permissions.get("contents") == "read" - assert all("checkout" not in value for value in uses_values) - if permissions.get("contents") == "write" and path != "reviewer-bot-privileged-commands.yml": - assert all("checkout" not in value for value in uses_values) - assert "Temporary lock debt" in text - for value in uses_values: - if value: - assert "@" in value and len(value.split("@", 1)[1]) == 40 - - -def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) - on_block = data.get("on", data.get(True)) - workflow_dispatch = on_block["workflow_dispatch"] - action_input = workflow_dispatch["inputs"]["action"] - assert "preview-reviewer-board" in action_input["options"] - issue_number_input = workflow_dispatch["inputs"]["issue_number"] - assert issue_number_input["required"] is False - assert issue_number_input["type"] == "string" - - -def test_sweeper_repair_workflow_scopes_reviewer_board_env_to_preview_only(): - workflow_text = Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8") - assert "ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}" in workflow_text - assert ( - "REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }}" - in workflow_text - ) - assert ( - "REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }}" - in workflow_text - ) - - -def test_pr_comment_observer_workflow_builds_payload_inline_without_bot_src_root(): - workflow = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") - assert "BOT_SRC_ROOT" not in workflow - assert "build_pr_comment_observer_payload" not in workflow - assert "Fetch trusted bot source tarball" not in workflow - - -def test_workflow_summaries_and_runbook_references_exist(): - runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md") - assert runbook.exists() - reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") - assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile - - -def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8")) - job = data["jobs"]["reviewer-bot-pr-comment-trusted"] - steps = job["steps"] - assert steps[0]["name"] == "Decide whether same-repo trusted path applies" - assert steps[1]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[2]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[3]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[4]["name"] == "Trusted path skipped" - assert steps[4]["if"] == "env.RUN_TRUSTED_PR_COMMENT != 'true'" - workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8") - assert "https://api.github.com/repos/{repo}/pulls/{pr_number}" in workflow_text - assert "RUN_TRUSTED_PR_COMMENT" in workflow_text - - -def test_pr_comment_observer_workflow_uses_inline_payload_builder(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8")) - job = data["jobs"]["observer"] - steps = job["steps"] - assert steps[0]["name"] == "Build deferred comment artifact" - assert steps[1]["name"] == "Upload deferred comment artifact" - workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") - assert "build_pr_comment_observer_payload" not in workflow_text - assert 'uv run --project "$BOT_SRC_ROOT"' not in workflow_text - - -def test_review_comment_observer_workflow_exists_and_is_read_only(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8")) - on_block = data.get("on", data.get(True)) - assert on_block["pull_request_review_comment"]["types"] == ["created"] - job = data["jobs"]["observer"] - assert job["permissions"]["contents"] == "read" - steps = job["steps"] - assert steps[0]["name"] == "Build deferred review comment artifact" - assert steps[1]["name"] == "Upload deferred review comment artifact" - workflow_text = Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8") - assert "checkout" not in workflow_text - assert "pull_request_review_comment" in workflow_text - - -def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "PLeVasseur") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") - monkeypatch.setenv("COMMENT_SENDER_TYPE", "User") - monkeypatch.setenv("COMMENT_INSTALLATION_ID", "") - monkeypatch.setenv("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_AUTHOR_ID", "123") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") - monkeypatch.setenv("GITHUB_RUN_ID", "999") - monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "PLeVasseur"}, - }, - ) - payload = reviewer_bot.build_pr_comment_observer_payload(42) - assert payload["kind"] == "observer_noop" - assert payload["reason"] == "trusted_direct_same_repo_human_comment" - assert payload["source_event_key"] == "issue_comment:100" - - -def test_issue_comment_direct_workflow_exports_issue_state(): - workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text(encoding="utf-8") - assert "ISSUE_STATE: ${{ github.event.issue.state }}" in workflow_text - - -def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency(): - workflow_paths = [ - ".github/workflows/reviewer-bot-issues.yml", - ".github/workflows/reviewer-bot-issue-comment-direct.yml", - ".github/workflows/reviewer-bot-sweeper-repair.yml", - ".github/workflows/reviewer-bot-pr-metadata.yml", - ".github/workflows/reviewer-bot-pr-comment-trusted.yml", - ".github/workflows/reviewer-bot-reconcile.yml", - ".github/workflows/reviewer-bot-privileged-commands.yml", - ] - for workflow_path in workflow_paths: - data = yaml.safe_load(Path(workflow_path).read_text(encoding="utf-8")) - for job in data.get("jobs", {}).values(): - assert "concurrency" not in job - - -def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) - - result = reviewer_bot.github_api_request("GET", "issues/42") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - - -def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "server_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - - -def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - - def always_fail(*args, **kwargs): - raise reviewer_bot.github_api_module.requests.RequestException("timeout") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "transport_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - assert "timeout" in str(result.transport_error) - - -def test_github_graphql_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - observed = {} - - def fake_post(url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) - - result = reviewer_bot.github_graphql_request( - "query { viewer { login } }", timeout_seconds=9.5 - ) - - assert result.ok is True - assert observed["timeout"] == 9.5 - - -def test_maybe_record_head_observation_repair_uses_github_api_fallback_after_system_exit(monkeypatch): - review_data = { - "active_head_sha": "head-0", - "contributor_revision": {"accepted": None}, - } - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "accept_channel_event", - lambda review_data, channel, **kwargs: True, - ) - - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) - - assert result.changed is True - assert result.outcome == "changed" - assert review_data["active_head_sha"] == "head-1" - - -def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): - monkeypatch.delenv("REPO_OWNER", raising=False) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) - - -def test_find_open_pr_for_branch_status_reports_found(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - [{"number": 42, "html_url": "https://example.com/pr/42"}], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") - - assert status == "found" - assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} - - -def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, [], {}, "ok", True, None, 0, None - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) - - -def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - - assert success is False - assert "Unable to verify triage permissions right now" in message - - -def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_pass_until_command( - state, - 42, - "alice", - "2099-01-01", - None, - ) - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - state["queue"] = [{"github": "alice", "name": "Alice"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_claim_command(state, 42, "alice") - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - response, success = reviewer_bot.handle_release_command(state, 42, "alice", ["@bob"]) - - assert success is False - assert "Unable to verify triage permissions right now" in response - - -def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_release_command(state, 42, "alice") - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monkeypatch): - state = make_state() - state["queue"] = [{"github": "alice", "name": "Alice"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) - - response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) - - assert success is False - assert "Unable to determine current assignees/reviewers" in response - - -def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") - - assert success is False - assert changed is False - assert "Unable to verify triage permissions right now" in message - - -def test_handle_rectify_command_reports_permission_denied(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") - - message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") - - assert success is False - assert changed is False - assert "Only maintainers with triage+ permission" in message - - -def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - ok, metadata = comment_routing._validate_accept_no_fls_changes_handoff( - reviewer_bot, - 42, - "alice", - ) - - assert ok is False - assert metadata["reason"] == "authorization_unavailable" - - -def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"] = { - "issue_comment:100": { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - assert reviewer_bot.handle_manual_dispatch(state) is True - pending = review["pending_privileged_commands"]["issue_comment:100"] - assert pending["status"] == "failed_closed" - assert pending["result"] == "live_permission_unavailable" - - -def test_get_pull_request_reviews_result_paginates(monkeypatch): - responses = { - "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult(200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None), - "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult(200, [{"id": 100}], {}, "ok", True, None, 0, None), - } - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], - ) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result["ok"] is True - assert len(result["reviews"]) == 101 - - -def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result == {"ok": True, "reviews": [{"id": 10}]} - - -def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, {"not": "a list"}, {}, "ok", True, None, 0, None - ), - ) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} - - -def test_pull_request_read_result_reports_not_found(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None - ), - ) - - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} - - -def test_pull_request_read_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None - ), - ) - - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} - - -def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), - ) - - with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): - reviewer_bot.ensure_lock_ref_exists() - - -def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): - responses = iter( - [ - reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), - reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) - - with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): - reviewer_bot.ensure_lock_ref_exists() - - -def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), - ) - - with pytest.raises(RuntimeError, match="Failed to read lock commit"): - reviewer_bot.get_lock_ref_snapshot() - - -def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): - payload = {"source_event_key": "issue_comment:100"} - responses = iter( - [ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ] - ) - success_response = FakeGitHubResponse(200, None, "") - success_response.content = make_zip_payload("deferred-comment.json", payload) - - def fake_request(*args, **kwargs): - response = next(responses, success_response) - return response - - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr(sweeper.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, artifact_payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "ok" - assert artifact_payload == payload - - -def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: (_ for _ in ()).throw( - sweeper.requests.RequestException("timeout") - ), - ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "download_unavailable" - assert payload is None - - -def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "download_unavailable" - assert payload is None - - -def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): - monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) - - assert sweeper._list_run_artifacts(reviewer_bot, 42) is None - - -@pytest.mark.parametrize( - ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), - [ - ( - {"source_event_name": "issue_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Comment Observer", - ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-comment-context-1-attempt-2", - "deferred-comment.json", - ), - ( - {"source_event_name": "pull_request_review", "source_event_action": "submitted", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Submitted Observer", - ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-review-submitted-context-1-attempt-2", - "deferred-review-submitted.json", - ), - ( - {"source_event_name": "pull_request_review", "source_event_action": "dismissed", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Dismissed Observer", - ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-review-dismissed-context-1-attempt-2", - "deferred-review-dismissed.json", - ), - ( - {"source_event_name": "pull_request_review_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Comment Observer", - ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-review-comment-context-1-attempt-2", - "deferred-review-comment.json", - ), - ], -) -def test_deferred_workflow_identity_helpers_match_expected_contract( - payload, - workflow_name, - workflow_file, - artifact_name, - payload_name, -): - assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( - workflow_name, - workflow_file, - ) - assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name - assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name - - -@pytest.mark.parametrize( - ("workflow_path", "artifact_name", "payload_name"), - [ - ( - ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-comment.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-review-submitted-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-submitted.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-review-dismissed-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-dismissed.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-review-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-comment.json", - ), - ], -) -def test_observer_workflow_files_match_expected_artifact_contract( - workflow_path, artifact_name, payload_name -): - workflow_text = Path(workflow_path).read_text(encoding="utf-8") - - assert artifact_name in workflow_text - assert payload_name in workflow_text - - -def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - - -def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="run_attempt mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - - -def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="did not conclude successfully"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - - -def test_reviewer_board_manifest_includes_projection_repair_attention_option(): - options = reviewer_bot.REVIEWER_BOARD_PROJECT_MANIFEST[ - reviewer_bot.REVIEWER_BOARD_FIELD_NEEDS_ATTENTION - ]["options"] - - assert reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options - - -def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING - - -def test_main_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "plain text") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - saved_states = [] - - def fake_load_state(*, fail_on_unavailable=False): - return json.loads(json.dumps(state)) - - def fake_save_state(updated_state): - saved_states.append(json.loads(json.dumps(updated_state))) - state.clear() - state.update(json.loads(json.dumps(updated_state))) - return True - - monkeypatch.setattr(reviewer_bot, "load_state", fake_load_state) - monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current_state: (current_state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current_state: (current_state, [])) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed"))) - output_path = tmp_path / "github-output.txt" - monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) - reviewer_bot.app_module.main(reviewer_bot) - assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" - assert len(saved_states) >= 2 diff --git a/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py b/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py new file mode 100644 index 000000000..9d9e5fd0d --- /dev/null +++ b/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py @@ -0,0 +1,38 @@ +from typing import get_type_hints + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import ( + GitHubTransportContext, + LeaseLockContext, + ReviewerBotContext, + StateStoreContext, +) + + +def test_reviewer_bot_exports_runtime_modules(): + assert reviewer_bot.requests is not None + assert reviewer_bot.sys is not None + assert reviewer_bot.datetime is not None + assert reviewer_bot.timezone is not None + +def test_reviewer_bot_satisfies_runtime_context_protocol(): + assert isinstance(reviewer_bot, ReviewerBotContext) + +def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): + assert isinstance(reviewer_bot, GitHubTransportContext) + assert isinstance(reviewer_bot, StateStoreContext) + assert isinstance(reviewer_bot, LeaseLockContext) + +def test_render_lock_commit_message_uses_direct_json_import(): + rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) + assert reviewer_bot.LOCK_COMMIT_MARKER in rendered + +def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): + hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + +def test_runtime_context_protocol_exposes_structured_head_repair_contract(): + hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult diff --git a/.github/reviewer-bot-tests/test_reviews.py b/.github/reviewer-bot-tests/test_reviews.py new file mode 100644 index 000000000..ab1455806 --- /dev/null +++ b/.github/reviewer-bot-tests/test_reviews.py @@ -0,0 +1,877 @@ +import json + +import pytest +from factories import make_state + +from scripts import reviewer_bot + + +def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "review_head_stale" + +def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + +def test_project_status_labels_uses_live_review_fallback_for_stale_head(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "review_head_stale" + +def test_project_status_labels_prefers_current_head_review_over_newer_stale_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 11, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:01:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + +def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_contributor_response" + assert response_state["reason"] == "completion_missing" + assert response_state["reviewer_review"]["semantic_key"] == "pull_request_review:10" + assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" + +def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + +def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_revision", + semantic_key="pull_request_sync:42:head-1", + timestamp="2026-03-17T12:00:00Z", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_reviewer_response" + assert response_state["reason"] == "contributor_revision_newer" + +def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "vccjgust" + review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-02-18T09:30:00Z", + actor="dana", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 30, + "state": "COMMENTED", + "submitted_at": "2026-02-18T10:00:00Z", + "commit_id": "head-older", + "user": {"login": "vccjgust"}, + }, + { + "id": 31, + "state": "COMMENTED", + "submitted_at": "2026-02-18T11:00:00Z", + "commit_id": "head-current", + "user": {"login": "vccjgust"}, + }, + ], + ) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + +def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-03-17T10:05:00Z", + actor="bob", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "contributor_comment_newer" + +def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill(): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["last_reviewer_activity"] = "2026-03-20T10:00:00Z" + review["transition_warning_sent"] = "2026-03-21T10:00:00Z" + review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" + reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") + assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + +def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "bob"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert metadata["state"] == "awaiting_write_approval" + review["mandatory_approver_required"] = True + desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + +def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_unavailable" + +def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid( + monkeypatch, +): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {}}, + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_head_unavailable" + +def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "permission_unavailable" + +def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="server_error"): + reviewer_bot.list_open_items_with_status_labels() + +def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" + assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] + +def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change( + monkeypatch, +): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( + reviewer_bot, + 42, + review, + ) + + assert changed is True + assert preferred_review is not None + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + +def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_reviewer_response" + assert review == before + +def test_compute_pr_approval_state_result_is_pure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + + result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) + + assert result["ok"] is True + assert result["completion"]["completed"] is True + assert review == before + +def test_apply_pr_approval_state_mutates_expected_fields(): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + + reviewer_bot.reviews_module.apply_pr_approval_state( + review, + completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, + write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, + current_head_sha="head-1", + ) + + assert review["active_head_sha"] == "head-1" + assert review["current_cycle_completion"]["completed"] is True + assert review["current_cycle_write_approval"]["has_write_approval"] is True + assert review["review_completion_source"] == "live_review_rebuild" + +def test_get_pull_request_reviews_result_paginates(monkeypatch): + responses = { + "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult(200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None), + "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult(200, [{"id": 100}], {}, "ok", True, None, 0, None), + } + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result["ok"] is True + assert len(result["reviews"]) == 101 + +def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": True, "reviews": [{"id": 10}]} + +def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, {"not": "a list"}, {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} + +def test_pull_request_read_result_reports_not_found(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} + +def test_pull_request_read_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} diff --git a/.github/reviewer-bot-tests/test_state_store.py b/.github/reviewer-bot-tests/test_state_store.py new file mode 100644 index 000000000..8df2c5fc9 --- /dev/null +++ b/.github/reviewer-bot-tests/test_state_store.py @@ -0,0 +1,104 @@ +from factories import make_state + +from scripts import reviewer_bot + + +def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): + monkeypatch.setattr(reviewer_bot, "get_state_issue", lambda: {"body": "queue: []\n"}) + state = reviewer_bot.load_state() + assert state["schema_version"] == reviewer_bot.STATE_SCHEMA_VERSION + assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY + +def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["retry_policy"] = kwargs.get("retry_policy") + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, + headers={"etag": '"abc"'}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + snapshot = reviewer_bot.get_state_issue_snapshot() + + assert snapshot is not None + assert snapshot.etag == '"abc"' + assert observed["retry_policy"] == "idempotent_read" + +def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": data["body"]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') + + assert observed["extra_headers"] == {"If-Match": '"etag-1"'} + +def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", None) + + assert observed["extra_headers"] is None + +def test_save_state_retries_precondition_failed_conflict(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + snapshot = reviewer_bot.StateIssueSnapshot( + body="body", + etag='"etag"', + html_url="https://example.com/state/1", + ) + responses = iter( + [ + reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), + reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) + monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) + monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) + monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) + monkeypatch.setattr( + reviewer_bot, + "render_state_issue_body", + lambda state_obj, lock_meta, base_body: "updated", + ) + monkeypatch.setattr( + reviewer_bot, + "conditional_patch_state_issue", + lambda body, etag=None: next(responses), + ) + monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) + + assert reviewer_bot.save_state(state) is True diff --git a/.github/reviewer-bot-tests/test_sweeper.py b/.github/reviewer-bot-tests/test_sweeper.py new file mode 100644 index 000000000..066a80c41 --- /dev/null +++ b/.github/reviewer-bot-tests/test_sweeper.py @@ -0,0 +1,464 @@ +import os +from pathlib import Path + +import pytest +import yaml +from factories import make_state, make_zip_payload +from fakes import FakeGitHubResponse + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import sweeper + + +def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_reviewer_review_from_live_review", + lambda review_data, live_review, actor=None: False, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, actor=None: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + + changed = sweeper._repair_visible_review_gap( + reviewer_bot, + review, + 42, + "pull_request_review:303", + { + "id": 303, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + ) + + assert changed is True + assert "pull_request_review:303" in review["reconciled_source_events"] + assert "pull_request_review:303" not in review["deferred_gaps"] + +def test_observer_run_reason_mapping_and_near_miss_signature(): + signature = {"status": "waiting", "conclusion": None, "name": "approval_pending"} + assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "approval_pending"}, signature) == "awaiting_observer_approval" + assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "almost"}, signature) == "observer_state_unknown" + +def test_negative_missing_run_requires_full_scan_and_recheck(): + gap = { + "source_event_created_at": "2026-03-15T00:00:00Z", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run_found": False, + "approval_pending_evidence_retained": False, + } + assert sweeper.can_mark_observer_run_missing(gap) is True + gap["later_recheck_complete"] = False + assert sweeper.can_mark_observer_run_missing(gap) is False + +def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_window(): + os.environ["GITHUB_REPOSITORY"] = "rustfoundation/safety-critical-rust-coding-guidelines" + result = sweeper.correlate_candidate_observer_runs( + "issue_comment:101", + source_event_kind="issue_comment:created", + source_event_created_at="2026-03-17T10:00:00Z", + pr_number=42, + workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", + workflow_runs=[ + { + "id": 1, + "event": "issue_comment", + "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "created_at": "2026-03-17T10:05:00Z", + "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, + "pull_requests": [{"number": 42}], + }, + { + "id": 2, + "event": "issue_comment", + "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "created_at": "2026-03-17T10:40:00Z", + "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, + "pull_requests": [{"number": 42}], + }, + ], + ) + assert result["candidate_run_ids"] == [1] + +def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): + result = sweeper.correlate_run_artifacts_exact( + { + 10: [{"source_event_key": "issue_comment:101", "source_run_id": 10, "source_run_attempt": 1, "pr_number": 42}], + 11: [{"source_event_key": "issue_comment:101", "source_run_id": 11, "source_run_attempt": 1, "pr_number": 42}], + }, + "issue_comment:101", + pr_number=42, + ) + assert result["status"] == "observer_state_unknown" + assert result["reason"] == "ambiguous_exact_artifact_matches" + +def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"artifacts": [{"id": 1, "name": "artifact"}]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ), + ) + + assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] + +def test_evaluate_gap_state_treats_artifact_download_unavailable_as_unknown(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "download_unavailable"}}, + ) + assert reason == "observer_state_unknown" + assert diagnostic == "artifact_download_unavailable" + +def test_evaluate_gap_state_only_emits_missing_after_negative_inference_contract(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + { + "source_event_created_at": "2026-03-15T00:00:00Z", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run_found": False, + "approval_pending_evidence_retained": False, + }, + { + "status": "no_candidate_runs", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run": None, + }, + None, + None, + ) + assert reason == "observer_run_missing" + assert diagnostic == "negative_inference_satisfied" + +def test_evaluate_gap_state_completed_success_without_exact_artifact_is_artifact_missing(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "reason": "no_exact_source_event_key_match"}, + ) + assert reason == "artifact_missing" + assert diagnostic == "no_exact_source_event_key_match" + +def test_evaluate_gap_state_completed_success_with_expired_artifact_marks_artifact_expired(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "expired"}}, + ) + assert reason == "artifact_expired" + assert diagnostic == "prior_visibility_or_retention_proof_required" + +def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention(): + expired = { + "artifact_seen_at": "2026-03-10T00:00:00Z", + "run_created_at": "2026-03-10T00:00:00Z", + } + assert sweeper.classify_artifact_gap_reason(expired) == "artifact_expired" + missing = { + "artifact_inspection_complete": True, + "run_created_at": "2026-03-17T00:00:00Z", + } + assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" + +def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: [ + { + "id": 100, + "created_at": "2026-03-25T10:00:00Z", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + { + "id": 101, + "created_at": "2026-03-25T11:00:00Z", + "user": {"login": "alice", "type": "User"}, + }, + ], + ) + discovered, complete = sweeper._discover_visible_comment_events(reviewer_bot, 42, review) + assert complete is True + assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] + +def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): + run_correlation = { + "candidate_run_ids": [123], + "correlated_run": None, + "correlated_run_found": False, + } + monkeypatch.setattr(sweeper, "_fetch_run_detail", lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}) + detail = sweeper._maybe_fetch_single_candidate_run_detail(reviewer_bot, run_correlation, {"status": "no_exact_artifact_match"}) + assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} + assert run_correlation["correlated_run"] == 123 + +def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else {"workflow_runs": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 202, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + assert "pull_request_review:202" not in review["deferred_gaps"] + assert "pull_request_review:202" in review["reconciled_source_events"] + +def test_workflow_policy_split_and_lock_only_boundaries(): + workflows_dir = Path(".github/workflows") + required = { + "reviewer-bot-issues.yml", + "reviewer-bot-issue-comment-direct.yml", + "reviewer-bot-sweeper-repair.yml", + "reviewer-bot-pr-metadata.yml", + "reviewer-bot-pr-comment-trusted.yml", + "reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-reconcile.yml", + "reviewer-bot-privileged-commands.yml", + } + assert required.issubset({path.name for path in workflows_dir.glob("reviewer-bot-*.yml")}) + for path in required: + data = yaml.safe_load((workflows_dir / path).read_text(encoding="utf-8")) + jobs = data.get("jobs", {}) + for job in jobs.values(): + permissions = job.get("permissions", {}) + steps = job.get("steps", []) + uses_values = [step.get("uses", "") for step in steps if isinstance(step, dict)] + text = (workflows_dir / path).read_text(encoding="utf-8") + if "observer" in path: + assert permissions.get("contents") == "read" + assert all("checkout" not in value for value in uses_values) + if permissions.get("contents") == "write" and path != "reviewer-bot-privileged-commands.yml": + assert all("checkout" not in value for value in uses_values) + assert "Temporary lock debt" in text + for value in uses_values: + if value: + assert "@" in value and len(value.split("@", 1)[1]) == 40 + +def test_pr_comment_observer_workflow_builds_payload_inline_without_bot_src_root(): + workflow = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") + assert "BOT_SRC_ROOT" not in workflow + assert "build_pr_comment_observer_payload" not in workflow + assert "Fetch trusted bot source tarball" not in workflow + +def test_workflow_summaries_and_runbook_references_exist(): + runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md") + assert runbook.exists() + reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") + assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile + +def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8")) + job = data["jobs"]["reviewer-bot-pr-comment-trusted"] + steps = job["steps"] + assert steps[0]["name"] == "Decide whether same-repo trusted path applies" + assert steps[1]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[2]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[3]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[4]["name"] == "Trusted path skipped" + assert steps[4]["if"] == "env.RUN_TRUSTED_PR_COMMENT != 'true'" + workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8") + assert "https://api.github.com/repos/{repo}/pulls/{pr_number}" in workflow_text + assert "RUN_TRUSTED_PR_COMMENT" in workflow_text + +def test_pr_comment_observer_workflow_uses_inline_payload_builder(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8")) + job = data["jobs"]["observer"] + steps = job["steps"] + assert steps[0]["name"] == "Build deferred comment artifact" + assert steps[1]["name"] == "Upload deferred comment artifact" + workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") + assert "build_pr_comment_observer_payload" not in workflow_text + assert 'uv run --project "$BOT_SRC_ROOT"' not in workflow_text + +def test_review_comment_observer_workflow_exists_and_is_read_only(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8")) + on_block = data.get("on", data.get(True)) + assert on_block["pull_request_review_comment"]["types"] == ["created"] + job = data["jobs"]["observer"] + assert job["permissions"]["contents"] == "read" + steps = job["steps"] + assert steps[0]["name"] == "Build deferred review comment artifact" + assert steps[1]["name"] == "Upload deferred review comment artifact" + workflow_text = Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8") + assert "checkout" not in workflow_text + assert "pull_request_review_comment" in workflow_text + +def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency(): + workflow_paths = [ + ".github/workflows/reviewer-bot-issues.yml", + ".github/workflows/reviewer-bot-issue-comment-direct.yml", + ".github/workflows/reviewer-bot-sweeper-repair.yml", + ".github/workflows/reviewer-bot-pr-metadata.yml", + ".github/workflows/reviewer-bot-pr-comment-trusted.yml", + ".github/workflows/reviewer-bot-reconcile.yml", + ".github/workflows/reviewer-bot-privileged-commands.yml", + ] + for workflow_path in workflow_paths: + data = yaml.safe_load(Path(workflow_path).read_text(encoding="utf-8")) + for job in data.get("jobs", {}).values(): + assert "concurrency" not in job + +def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): + payload = {"source_event_key": "issue_comment:100"} + responses = iter( + [ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ] + ) + success_response = FakeGitHubResponse(200, None, "") + success_response.content = make_zip_payload("deferred-comment.json", payload) + + def fake_request(*args, **kwargs): + response = next(responses, success_response) + return response + + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(sweeper.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, artifact_payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "ok" + assert artifact_payload == payload + +def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: (_ for _ in ()).throw( + sweeper.requests.RequestException("timeout") + ), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + +def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + +def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): + monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) + + assert sweeper._list_run_artifacts(reviewer_bot, 42) is None + + +@pytest.mark.parametrize( + ("workflow_path", "artifact_name", "payload_name"), + [ + ( + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-comment.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-submitted.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-dismissed.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-comment.json", + ), + ], +) +def test_observer_workflow_files_match_expected_artifact_contract( + workflow_path, artifact_name, payload_name +): + workflow_text = Path(workflow_path).read_text(encoding="utf-8") + + assert artifact_name in workflow_text + assert payload_name in workflow_text From b1651e52d772a907cdefedf808b83b5479187d4e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 05:00:25 +0900 Subject: [PATCH 004/209] test(reviewer-bot): preserve comment routing coverage parity --- .github/reviewer-bot-tests/test_comment_routing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/reviewer-bot-tests/test_comment_routing.py b/.github/reviewer-bot-tests/test_comment_routing.py index a06819d5d..77703211a 100644 --- a/.github/reviewer-bot-tests/test_comment_routing.py +++ b/.github/reviewer-bot-tests/test_comment_routing.py @@ -45,8 +45,9 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_ ("env", "expected"), [ ({"COMMENT_USER_TYPE": "Bot", "COMMENT_AUTHOR": "reviewer-bot"}, "bot_account"), + ({"COMMENT_INSTALLATION_ID": "12345"}, "github_app_or_other_automation"), ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice"}, "repo_user_principal"), - ({"COMMENT_AUTHOR": "mystery"}, "repo_user_principal"), + ({"COMMENT_AUTHOR": "mystery", "COMMENT_USER_TYPE": ""}, "unknown_actor"), ], ) def test_classify_issue_comment_actor(monkeypatch, env, expected): From 859dc538169449f68a5098276a842aa12fbd53ce Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:28:53 +0900 Subject: [PATCH 005/209] ci(reviewer-bot): watch reviewer bot library changes --- .github/workflows/reviewer-bot-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index eac04986f..10bc48d78 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -4,6 +4,7 @@ on: push: paths: - 'scripts/reviewer_bot.py' + - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' - '.github/workflows/reviewer-bot-*.yml' - '.github/reviewer-bot-tests/**' @@ -11,6 +12,7 @@ on: pull_request: paths: - 'scripts/reviewer_bot.py' + - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' - '.github/workflows/reviewer-bot-*.yml' - '.github/reviewer-bot-tests/**' From dd2d757ac6d8a2878244598a8517826fcd7c5ce9 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:30:35 +0900 Subject: [PATCH 006/209] test(reviewer-bot): add importlib-ready test support tree --- tests/__init__.py | 0 tests/conftest.py | 141 +++++++++++++++++++++++++++++++++ tests/fixtures/__init__.py | 0 tests/fixtures/github.py | 12 +++ tests/fixtures/reviewer_bot.py | 97 +++++++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/github.py create mode 100644 tests/fixtures/reviewer_bot.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..2757cc93d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,141 @@ +import pytest + +from scripts import reviewer_bot + + +@pytest.fixture(autouse=True) +def clear_reviewer_bot_env(): + env_vars = { + "COMMENT_BODY", + "COMMENT_AUTHOR", + "COMMENT_ID", + "COMMENT_SOURCE_EVENT_KEY", + "ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", + "EVENT_ACTION", + "EVENT_NAME", + "ISSUE_NUMBER", + "ISSUE_AUTHOR", + "IS_PULL_REQUEST", + "ISSUE_LABELS", + "LABEL_NAME", + "MANUAL_ACTION", + "PR_IS_CROSS_REPOSITORY", + "REVIEW_AUTHOR", + "REVIEW_STATE", + "REPO_OWNER", + "REPO_NAME", + "WORKFLOW_RUN_EVENT", + "WORKFLOW_RUN_EVENT_ACTION", + "WORKFLOW_RUN_HEAD_SHA", + "WORKFLOW_RUN_RECONCILE_PR_NUMBER", + "WORKFLOW_RUN_RECONCILE_HEAD_SHA", + "WORKFLOW_RUN_ID", + "WORKFLOW_NAME", + "WORKFLOW_JOB_NAME", + } + with pytest.MonkeyPatch().context() as monkeypatch: + for name in env_vars: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr( + reviewer_bot, + "ACTIVE_LEASE_CONTEXT", + reviewer_bot.LeaseContext( + lock_token="test-lock-token", + lock_owner_run_id="test-run", + lock_owner_workflow="test-workflow", + lock_owner_job="test-job", + state_issue_url="https://example.com/state", + ), + ) + monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + yield + + +@pytest.fixture +def setenv_many(monkeypatch): + def setter(values: dict[str, str]): + for key, value in values.items(): + monkeypatch.setenv(key, value) + + return setter + + +@pytest.fixture +def lease_context(): + return reviewer_bot.LeaseContext( + lock_token="test-lock-token", + lock_owner_run_id="test-run", + lock_owner_workflow="test-workflow", + lock_owner_job="test-job", + state_issue_url="https://example.com/state", + ) + + +@pytest.fixture +def tmp_deferred_path(tmp_path): + return tmp_path / "deferred-context.json" + + +@pytest.fixture +def stub_api(monkeypatch): + monkeypatch.setattr(reviewer_bot, "github_api", lambda *args, **kwargs: {}) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={}, + headers={}, + text="", + ok=True, + ), + ) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "assign_reviewer", lambda *args, **kwargs: True) + monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda *args, **kwargs: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + ) + monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "remove_pr_reviewer", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "remove_assignee", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda *args, **kwargs: {"a", "b"}) + monkeypatch.setattr(reviewer_bot, "add_label", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "add_label_with_status", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "remove_label", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "remove_label_with_status", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "fetch_members", lambda *args, **kwargs: []) + + +@pytest.fixture +def captured_comments(monkeypatch): + comments = [] + + def record_comment(issue_number, body): + comments.append({"issue_number": issue_number, "body": body}) + return True + + monkeypatch.setattr(reviewer_bot, "post_comment", record_comment) + return comments + + +@pytest.fixture +def captured_status_label_ops(monkeypatch): + operations = [] + + def record_add(issue_number, label): + operations.append(("add", issue_number, label)) + return True + + def record_remove(issue_number, label): + operations.append(("remove", issue_number, label)) + return True + + monkeypatch.setattr(reviewer_bot, "add_label_with_status", record_add) + monkeypatch.setattr(reviewer_bot, "remove_label_with_status", record_remove) + monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) + return operations diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/github.py b/tests/fixtures/github.py new file mode 100644 index 000000000..26e17e48c --- /dev/null +++ b/tests/fixtures/github.py @@ -0,0 +1,12 @@ +class FakeGitHubResponse: + def __init__(self, status_code, payload=None, text="", headers=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + self.content = b"" if payload is None and not text else b"x" + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload diff --git a/tests/fixtures/reviewer_bot.py b/tests/fixtures/reviewer_bot.py new file mode 100644 index 000000000..616f15e83 --- /dev/null +++ b/tests/fixtures/reviewer_bot.py @@ -0,0 +1,97 @@ +import io +import json +import zipfile + +from scripts import reviewer_bot + + +def make_state(epoch: str | None = None): + state = { + "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, + "freshness_runtime_epoch": epoch or reviewer_bot.FRESHNESS_RUNTIME_EPOCH_V18, + "status_projection_epoch": reviewer_bot.STATUS_PROJECTION_EPOCH, + "last_updated": None, + "current_index": 0, + "queue": [ + {"github": "alice", "name": "Alice"}, + {"github": "bob", "name": "Bob"}, + {"github": "carol", "name": "Carol"}, + ], + "pass_until": [], + "recent_assignments": [], + "active_reviews": {}, + } + if epoch == "freshness_v15": + state["freshness_runtime_epoch"] = epoch + state.pop("status_projection_epoch", None) + return state + + +def valid_reviewer_board_metadata(): + return { + "data": { + "organization": { + "projectV2": { + "id": "PVT_kwDOB", + "title": "Reviewer Board", + "fields": { + "nodes": [ + { + "__typename": "ProjectV2SingleSelectField", + "id": "field-review-state", + "name": "Review State", + "options": [ + {"id": "opt-ar", "name": "Awaiting Reviewer"}, + {"id": "opt-ac", "name": "Awaiting Contributor"}, + {"id": "opt-aw", "name": "Awaiting Write Approval"}, + {"id": "opt-done", "name": "Done"}, + {"id": "opt-unassigned", "name": "Unassigned"}, + ], + }, + { + "__typename": "ProjectV2Field", + "dataType": "TEXT", + "id": "field-reviewer", + "name": "Reviewer", + }, + { + "__typename": "ProjectV2Field", + "dataType": "DATE", + "id": "field-assigned-at", + "name": "Assigned At", + }, + { + "__typename": "ProjectV2Field", + "dataType": "DATE", + "id": "field-waiting-since", + "name": "Waiting Since", + }, + { + "__typename": "ProjectV2SingleSelectField", + "id": "field-needs-attention", + "name": "Needs Attention", + "options": [ + {"id": "opt-no", "name": "No"}, + {"id": "opt-warning", "name": "Warning Sent"}, + {"id": "opt-notice", "name": "Transition Notice Sent"}, + {"id": "opt-triage", "name": "Triage Approval Required"}, + {"id": "opt-repair", "name": "Projection Repair Required"}, + ], + }, + ] + }, + } + } + } + } + + +def iso_z(dt): + return dt.isoformat().replace("+00:00", "Z") + + +def make_zip_payload(file_name: str, payload: dict) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(file_name, json.dumps(payload)) + return buffer.getvalue() From d7284bd71aced2f9ef792cc29ff9ef92d2e823c5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:31:36 +0900 Subject: [PATCH 007/209] ci(reviewer-bot): enforce targeted coverage floors --- .github/workflows/reviewer-bot-tests.yml | 36 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index 10bc48d78..3d2922de0 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -6,6 +6,7 @@ on: - 'scripts/reviewer_bot.py' - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' + - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - '.github/reviewer-bot-tests/**' - 'pyproject.toml' @@ -14,6 +15,7 @@ on: - 'scripts/reviewer_bot.py' - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' + - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - '.github/reviewer-bot-tests/**' - 'pyproject.toml' @@ -32,5 +34,35 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Run reviewer bot tests - run: uv run pytest .github/reviewer-bot-tests + - name: Run reviewer bot tests with targeted coverage + run: >- + uv run pytest .github/reviewer-bot-tests + --cov=scripts.reviewer_bot + --cov=scripts.reviewer_bot_lib + --cov-branch + --cov-report=term-missing + --cov-report=json:coverage-reviewer-bot.json + + - name: Enforce reviewer bot statement and branch floors + run: | + uv run python - <<'PY' + import json + + with open("coverage-reviewer-bot.json", encoding="utf-8") as handle: + totals = json.load(handle)["totals"] + + statements = totals["percent_statements_covered"] + branches = totals["percent_branches_covered"] + + print(f"reviewer-bot statements: {statements:.2f}%") + print(f"reviewer-bot branches: {branches:.2f}%") + + if statements < 66: + raise SystemExit( + f"reviewer-bot statement coverage below floor: {statements:.2f}% < 66%" + ) + if branches < 48: + raise SystemExit( + f"reviewer-bot branch coverage below floor: {branches:.2f}% < 48%" + ) + PY From b32969d42df7b8bd00638c1078921c7dc0b06f7f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:34:13 +0900 Subject: [PATCH 008/209] test(reviewer-bot): mirror github api tests under tests --- tests/unit/reviewer_bot/test_github_api.py | 427 +++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_github_api.py diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py new file mode 100644 index 000000000..5e0a266c3 --- /dev/null +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -0,0 +1,427 @@ +import pytest + +from scripts import reviewer_bot +from tests.fixtures.github import FakeGitHubResponse + + +def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses) + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + assert result.failure_kind is None + + +def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"ok": True} + assert result.retry_attempts == 1 + + +def test_github_api_request_classifies_not_found_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(404, {"message": "missing"}, "missing") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "not_found" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + + +def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + calls = {"count": 0} + + def fake_request(*args, **kwargs): + calls["count"] += 1 + return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "forbidden" + assert result.retry_attempts == 0 + assert calls["count"] == 1 + + +def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + responses = iter( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), + ] + ) + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") + + assert result.ok is True + assert result.payload == {"data": {"viewer": {"login": "bot"}}} + assert result.retry_attempts == 1 + + +def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + responses = iter( + [ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + FakeGitHubResponse(200, {"ok": True}, "ok"), + ] + ) + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses) + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + + assert result.ok is True + assert result.retry_attempts == 1 + + +def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "rate_limited" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + + +def test_github_graphql_request_reports_invalid_payload(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_graphql_request_reports_graphql_errors(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "post", + lambda *args, **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom"), + ) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_api_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + observed = {} + + def fake_request(method, url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"ok": True}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + + result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) + + assert result.ok is True + assert observed["timeout"] == 12.5 + + +def test_github_api_request_rejects_idempotent_retry_for_non_get(): + with pytest.raises(ValueError, match="only valid for REST GET"): + reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") + + +def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): + with pytest.raises(ValueError, match="only valid for GraphQL queries"): + reviewer_bot.github_graphql_request( + "mutation { closeIssue(input: {}) { clientMutationId } }", + retry_policy="idempotent_read", + ) + + +def test_remove_label_reports_failure(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=500, + payload={"message": "boom"}, + headers={}, + text="boom", + ok=False, + failure_kind="server_error", + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False + + +def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" + assert reviewer_bot.check_user_permission("alice", "triage") is None + + +def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.get_issue_assignees(42) is None + + +def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"not": "a list"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + + +def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + + +def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), + ) + + result = reviewer_bot.github_api_request("GET", "issues/42") + + assert result.ok is False + assert result.failure_kind == "invalid_payload" + + +def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + monkeypatch.setattr( + reviewer_bot.github_api_module.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + ) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "server_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + + +def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + + def always_fail(*args, **kwargs): + raise reviewer_bot.github_api_module.requests.RequestException("timeout") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) + monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + + result = reviewer_bot.github_api_request( + "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True + ) + + assert result.ok is False + assert result.failure_kind == "transport_error" + assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert "timeout" in str(result.transport_error) + + +def test_github_graphql_request_passes_timeout(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + observed = {} + + def fake_post(url, headers=None, json=None, timeout=None): + observed["timeout"] = timeout + return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") + + monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) + + result = reviewer_bot.github_graphql_request("query { viewer { login } }", timeout_seconds=9.5) + + assert result.ok is True + assert observed["timeout"] == 9.5 + + +def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): + monkeypatch.delenv("REPO_OWNER", raising=False) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) + + +def test_find_open_pr_for_branch_status_reports_found(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + [{"number": 42, "html_url": "https://example.com/pr/42"}], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") + + assert status == "found" + assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} + + +def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, [], {}, "ok", True, None, 0, None + ), + ) + + assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) From 5623adb40f765c7380f028762583d172710ff131 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:36:49 +0900 Subject: [PATCH 009/209] test(reviewer-bot): mirror state store and adapter tests --- .../reviewer_bot/test_adapter_contract.py | 43 +++++++ tests/unit/reviewer_bot/test_state_store.py | 107 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/contract/reviewer_bot/test_adapter_contract.py create mode 100644 tests/unit/reviewer_bot/test_state_store.py diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py new file mode 100644 index 000000000..793714327 --- /dev/null +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -0,0 +1,43 @@ +from typing import get_type_hints + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import ( + GitHubTransportContext, + LeaseLockContext, + ReviewerBotContext, + StateStoreContext, +) + + +def test_reviewer_bot_exports_runtime_modules(): + assert reviewer_bot.requests is not None + assert reviewer_bot.sys is not None + assert reviewer_bot.datetime is not None + assert reviewer_bot.timezone is not None + + +def test_reviewer_bot_satisfies_runtime_context_protocol(): + assert isinstance(reviewer_bot, ReviewerBotContext) + + +def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): + assert isinstance(reviewer_bot, GitHubTransportContext) + assert isinstance(reviewer_bot, StateStoreContext) + assert isinstance(reviewer_bot, LeaseLockContext) + + +def test_render_lock_commit_message_uses_direct_json_import(): + rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) + assert reviewer_bot.LOCK_COMMIT_MARKER in rendered + + +def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): + hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + + +def test_runtime_context_protocol_exposes_structured_head_repair_contract(): + hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) + + assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py new file mode 100644 index 000000000..9e7cc1181 --- /dev/null +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -0,0 +1,107 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): + monkeypatch.setattr(reviewer_bot, "get_state_issue", lambda: {"body": "queue: []\n"}) + state = reviewer_bot.load_state() + assert state["schema_version"] == reviewer_bot.STATE_SCHEMA_VERSION + assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY + + +def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["retry_policy"] = kwargs.get("retry_policy") + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, + headers={"etag": '"abc"'}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + snapshot = reviewer_bot.get_state_issue_snapshot() + + assert snapshot is not None + assert snapshot.etag == '"abc"' + assert observed["retry_policy"] == "idempotent_read" + + +def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"body": data["body"]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') + + assert observed["extra_headers"] == {"If-Match": '"etag-1"'} + + +def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): + observed = {} + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + + reviewer_bot.conditional_patch_state_issue("updated", None) + + assert observed["extra_headers"] is None + + +def test_save_state_retries_precondition_failed_conflict(monkeypatch): + state = make_state() + monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) + snapshot = reviewer_bot.StateIssueSnapshot( + body="body", + etag='"etag"', + html_url="https://example.com/state/1", + ) + responses = iter( + [ + reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), + reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) + monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) + monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) + monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) + monkeypatch.setattr( + reviewer_bot, + "render_state_issue_body", + lambda state_obj, lock_meta, base_body: "updated", + ) + monkeypatch.setattr( + reviewer_bot, + "conditional_patch_state_issue", + lambda body, etag=None: next(responses), + ) + monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) + + assert reviewer_bot.save_state(state) is True From aa1a8042287dccd5f80752eca8a2e0eee2187956 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:36:49 +0900 Subject: [PATCH 010/209] ci(reviewer-bot): run migrated tests in parallel --- .github/workflows/reviewer-bot-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index 3d2922de0..d00261145 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -43,6 +43,9 @@ jobs: --cov-report=term-missing --cov-report=json:coverage-reviewer-bot.json + - name: Run migrated reviewer bot tests with importlib collection + run: uv run pytest tests --import-mode=importlib + - name: Enforce reviewer bot statement and branch floors run: | uv run python - <<'PY' From 2a0f88df59036dda2a15c90a492313fd3a8dcd68 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:40:09 +0900 Subject: [PATCH 011/209] test(reviewer-bot): mirror lease lock tests under tests --- tests/unit/reviewer_bot/test_lease_lock.py | 390 +++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_lease_lock.py diff --git a/tests/unit/reviewer_bot/test_lease_lock.py b/tests/unit/reviewer_bot/test_lease_lock.py new file mode 100644 index 000000000..a6961f197 --- /dev/null +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -0,0 +1,390 @@ +import pytest + +from scripts import reviewer_bot + + +def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref-2", "tree", {"lock_state": "unlocked", "lock_token": None}), + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "local-run", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ), + ] + ) + + monkeypatch.setattr( + reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() + ) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + reviewer_bot, + "create_lock_commit", + lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True + ), + ) + monkeypatch.setattr( + reviewer_bot, + "cas_update_lock_ref", + lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + ) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + context = reviewer_bot.acquire_state_issue_lease_lock() + + assert context.lock_token == "token-123" + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + + +def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), + ] + ) + + monkeypatch.setattr( + reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() + ) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + reviewer_bot, + "create_lock_commit", + lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True + ), + ) + monkeypatch.setattr( + reviewer_bot, + "cas_update_lock_ref", + lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + ) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + with pytest.raises(RuntimeError, match="unexpected lock state"): + reviewer_bot.acquire_state_issue_lease_lock() + + +def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "local-run", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ), + ] + ) + + monkeypatch.setattr( + reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() + ) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + reviewer_bot, + "create_lock_commit", + lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True + ), + ) + monkeypatch.setattr( + reviewer_bot, + "cas_update_lock_ref", + lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + ) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + context = reviewer_bot.acquire_state_issue_lease_lock() + + assert context.lock_token == "token-123" + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + + +def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): + monkeypatch.setattr( + reviewer_bot.lease_lock_module, + "get_lock_owner_context", + lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + ) + snapshots = iter( + [ + ( + "new-ref", + "tree", + { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "someone-else", + "lock_owner_workflow": "reviewer-bot", + "lock_owner_job": "reviewer-bot", + "lock_expires_at": "2999-01-01T00:00:00+00:00", + }, + ) + ] + ) + + monkeypatch.setattr( + reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() + ) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + + with pytest.raises(RuntimeError, match="owner metadata drifted"): + reviewer_bot.acquire_state_issue_lease_lock() + + +def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="token-123", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + snapshots = iter( + [ + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "token-123"}), + ] + ) + + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) + monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + reviewer_bot, + "create_lock_commit", + lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( + 201, {"sha": "commit-2"}, {}, "", True + ), + ) + monkeypatch.setattr( + reviewer_bot, + "cas_update_lock_ref", + lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + ) + + assert reviewer_bot.release_state_issue_lease_lock() is True + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + + +def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="token-123", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) + monkeypatch.setattr( + reviewer_bot, + "get_lock_ref_snapshot", + lambda: ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), + ) + + assert reviewer_bot.release_state_issue_lease_lock() is False + assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + + +def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): + observed = [] + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed.append((endpoint, kwargs.get("retry_policy"))) + if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: + return reviewer_bot.GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/reviewer-bot-state-lock": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "git/ref/heads/main": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"object": {"sha": "base-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + if endpoint == "git/refs": + return reviewer_bot.GitHubApiResult( + status_code=201, + payload={"ref": "refs/heads/reviewer-bot-state-lock"}, + headers={}, + text="created", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" + assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") + assert observed[1] == ("git/ref/heads/main", "idempotent_read") + + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult( + 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None + ), + reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") + monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + + with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"message": "missing tree"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="missing tree SHA"): + reviewer_bot.get_lock_ref_snapshot() + + +def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): + context = reviewer_bot.LeaseContext( + lock_token="expected-token", + lock_owner_run_id="run-1", + lock_owner_workflow="wf", + lock_owner_job="job", + state_issue_url="https://example.com/state/1", + ) + monkeypatch.setattr( + reviewer_bot, + "get_lock_ref_snapshot", + lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), + ) + + assert reviewer_bot.renew_state_issue_lease_lock(context) is False + + +def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): + responses = iter( + [ + reviewer_bot.GitHubApiResult( + 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None + ), + reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), + ] + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): + reviewer_bot.ensure_lock_ref_exists() + + +def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): + monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ), + ) + + with pytest.raises(RuntimeError, match="Failed to read lock commit"): + reviewer_bot.get_lock_ref_snapshot() From 6de6859c4dec344cc8c046c06d8abe899eea0a52 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:41:26 +0900 Subject: [PATCH 012/209] test(reviewer-bot): mirror overdue tests under tests --- tests/unit/reviewer_bot/test_overdue.py | 250 ++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_overdue.py diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py new file mode 100644 index 000000000..446358a91 --- /dev/null +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -0,0 +1,250 @@ +from datetime import timedelta + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import iso_z, make_state + + +def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-02T00:00:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + + +def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) + + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + + +def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) + + assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False + assert review["transition_warning_sent"] is None + + +def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): + now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) + assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) + contributor_comment_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": reviewer_review_at, + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_comment"]["accepted"] = { + "semantic_key": "issue_comment:20", + "timestamp": contributor_comment_at, + "actor": "bob", + "reviewed_head_sha": None, + "source_precedence": 0, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + assert overdue[0]["issue_number"] == 42 + assert overdue[0]["needs_warning"] is True + assert overdue[0]["days_overdue"] == 0 + + +def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_changes_after_review(monkeypatch): + now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) + assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) + contributor_revision_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": reviewer_review_at, + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_revision"]["accepted"] = { + "semantic_key": "pull_request_sync:42:head-2", + "timestamp": contributor_revision_at, + "actor": None, + "reviewed_head_sha": "head-2", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + assert overdue[0]["issue_number"] == 42 + assert overdue[0]["needs_warning"] is True + assert overdue[0]["days_overdue"] == 0 + + +def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-02T00:00:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["contributor_revision"]["accepted"] = { + "semantic_key": "pull_request_head_observed:42:head-1", + "timestamp": "2026-03-12T00:00:00Z", + "actor": None, + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + + +def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["assigned_at"] = "2026-03-01T00:00:00Z" + review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-02T00:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-20T00:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-21T00:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] From c232767f12df67b49c926d7e42c379b8e1206bb3 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:42:30 +0900 Subject: [PATCH 013/209] test(reviewer-bot): mirror maintenance tests under tests --- tests/unit/reviewer_bot/test_maintenance.py | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_maintenance.py diff --git a/tests/unit/reviewer_bot/test_maintenance.py b/tests/unit/reviewer_bot/test_maintenance.py new file mode 100644 index 000000000..092f095b1 --- /dev/null +++ b/tests/unit/reviewer_bot/test_maintenance.py @@ -0,0 +1,163 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + assert reviewer_bot.handle_scheduled_check(state) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted is not None + assert accepted["semantic_key"] == "pull_request_review:10" + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + + +def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): + state = make_state() + review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) + review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) + assert review_42 is not None + assert review_43 is not None + review_42["current_reviewer"] = "alice" + review_43["current_reviewer"] = "bob" + overdue_called = [] + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) + monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "check_overdue_reviews", + lambda bot, state: overdue_called.append(True) or [], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + + def fake_head_repair(bot, issue_number, review_data): + if issue_number == 42: + return reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="skipped_unavailable", + failure_kind="server_error", + reason="pull_request_unavailable", + ) + review_data["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "stale", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") + + monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert overdue_called == [True] + assert review_42["repair_needed"]["kind"] == "live_read_failure" + assert review_42["repair_needed"]["phase"] == "head_observation_repair" + assert review_42["repair_needed"]["failure_kind"] == "server_error" + assert review_43["repair_needed"] is None + + +def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) + monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) + + first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( + reviewer_bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + + assert first is True + assert second is False + assert review["repair_needed"]["recorded_at"] == "2026-03-01T00:00:00Z" + + +def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["repair_needed"] = { + "kind": "live_read_failure", + "phase": "head_observation_repair", + "reason": "pull_request_unavailable", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + + assert reviewer_bot.handle_scheduled_check(state) is True + assert review["repair_needed"] is None From 7708835a1de97bd0618db4ab9a7dd4888edb8a6e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 06:43:43 +0900 Subject: [PATCH 014/209] test(reviewer-bot): extract workflow contract checks --- .../test_privileged_commands_workflow.py | 13 ++++ .../test_reviewer_board_workflow_contracts.py | 27 +++++++ .../reviewer_bot/test_workflow_files.py | 77 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 tests/contract/reviewer_bot/test_privileged_commands_workflow.py create mode 100644 tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py create mode 100644 tests/contract/reviewer_bot/test_workflow_files.py diff --git a/tests/contract/reviewer_bot/test_privileged_commands_workflow.py b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py new file mode 100644 index 000000000..d473eaeb4 --- /dev/null +++ b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +def test_privileged_commands_workflow_executes_source_entrypoint(): + workflow_text = Path(".github/workflows/reviewer-bot-privileged-commands.yml").read_text( + encoding="utf-8" + ) + assert "Fetch trusted bot source tarball" in workflow_text + assert 'REVIEWER_BOT_TARGET_REPO_ROOT: ${{ github.workspace }}' in workflow_text + assert ( + 'run: uv run --project "$BOT_SRC_ROOT" python "$BOT_SRC_ROOT/scripts/reviewer_bot.py"' + in workflow_text + ) diff --git a/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py new file mode 100644 index 000000000..babf51b13 --- /dev/null +++ b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import yaml + + +def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) + on_block = data.get("on", data.get(True)) + workflow_dispatch = on_block["workflow_dispatch"] + action_input = workflow_dispatch["inputs"]["action"] + assert "preview-reviewer-board" in action_input["options"] + issue_number_input = workflow_dispatch["inputs"]["issue_number"] + assert issue_number_input["required"] is False + assert issue_number_input["type"] == "string" + + +def test_sweeper_repair_workflow_scopes_reviewer_board_env_to_preview_only(): + workflow_text = Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8") + assert "ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}" in workflow_text + assert ( + "REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }}" + in workflow_text + ) + assert ( + "REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }}" + in workflow_text + ) diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py new file mode 100644 index 000000000..80b409d0e --- /dev/null +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import yaml + + +def test_issue_comment_direct_workflow_exports_issue_state(): + workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text( + encoding="utf-8" + ) + assert "ISSUE_STATE: ${{ github.event.issue.state }}" in workflow_text + + +def test_pr_comment_observer_workflow_builds_payload_inline_without_bot_src_root(): + workflow = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") + assert "BOT_SRC_ROOT" not in workflow + assert "build_pr_comment_observer_payload" not in workflow + assert "Fetch trusted bot source tarball" not in workflow + + +def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8")) + job = data["jobs"]["reviewer-bot-pr-comment-trusted"] + steps = job["steps"] + assert steps[0]["name"] == "Decide whether same-repo trusted path applies" + assert steps[1]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[2]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[3]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" + assert steps[4]["name"] == "Trusted path skipped" + assert steps[4]["if"] == "env.RUN_TRUSTED_PR_COMMENT != 'true'" + workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8") + assert "https://api.github.com/repos/{repo}/pulls/{pr_number}" in workflow_text + assert "RUN_TRUSTED_PR_COMMENT" in workflow_text + + +def test_pr_comment_observer_workflow_uses_inline_payload_builder(): + data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8")) + job = data["jobs"]["observer"] + steps = job["steps"] + assert steps[0]["name"] == "Build deferred comment artifact" + assert steps[1]["name"] == "Upload deferred comment artifact" + workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") + assert "build_pr_comment_observer_payload" not in workflow_text + assert 'uv run --project "$BOT_SRC_ROOT"' not in workflow_text + + +def test_review_comment_observer_workflow_exists_and_is_read_only(): + data = yaml.safe_load( + Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8") + ) + on_block = data.get("on", data.get(True)) + assert on_block["pull_request_review_comment"]["types"] == ["created"] + job = data["jobs"]["observer"] + assert job["permissions"]["contents"] == "read" + steps = job["steps"] + assert steps[0]["name"] == "Build deferred review comment artifact" + assert steps[1]["name"] == "Upload deferred review comment artifact" + workflow_text = Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text( + encoding="utf-8" + ) + assert "checkout" not in workflow_text + assert "pull_request_review_comment" in workflow_text + + +def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency(): + workflow_paths = [ + ".github/workflows/reviewer-bot-issues.yml", + ".github/workflows/reviewer-bot-issue-comment-direct.yml", + ".github/workflows/reviewer-bot-sweeper-repair.yml", + ".github/workflows/reviewer-bot-pr-metadata.yml", + ".github/workflows/reviewer-bot-pr-comment-trusted.yml", + ".github/workflows/reviewer-bot-reconcile.yml", + ".github/workflows/reviewer-bot-privileged-commands.yml", + ] + for workflow_path in workflow_paths: + data = yaml.safe_load(Path(workflow_path).read_text(encoding="utf-8")) + for job in data.get("jobs", {}).values(): + assert "concurrency" not in job From a5117f7443ea8a4a0710829d0b641b2cb01d73b7 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 07:21:39 +0900 Subject: [PATCH 015/209] test(reviewer-bot): extract artifact contract checks --- .../test_workflow_artifact_contracts.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/contract/reviewer_bot/test_workflow_artifact_contracts.py diff --git a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py new file mode 100644 index 000000000..1e3ab13d0 --- /dev/null +++ b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py @@ -0,0 +1,156 @@ +from pathlib import Path + +import pytest + +from scripts import reviewer_bot + + +@pytest.mark.parametrize( + ("workflow_path", "artifact_name", "payload_name"), + [ + ( + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-comment.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-submitted.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-dismissed.json", + ), + ( + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", + "deferred-review-comment.json", + ), + ], +) +def test_observer_workflow_files_match_expected_artifact_contract( + workflow_path, artifact_name, payload_name +): + workflow_text = Path(workflow_path).read_text(encoding="utf-8") + + assert artifact_name in workflow_text + assert payload_name in workflow_text + + +@pytest.mark.parametrize( + ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), + [ + ( + { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_run_id": 1, + "source_run_attempt": 2, + }, + "Reviewer Bot PR Comment Observer", + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-comment-context-1-attempt-2", + "deferred-comment.json", + ), + ( + { + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_run_id": 1, + "source_run_attempt": 2, + }, + "Reviewer Bot PR Review Submitted Observer", + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-review-submitted-context-1-attempt-2", + "deferred-review-submitted.json", + ), + ( + { + "source_event_name": "pull_request_review", + "source_event_action": "dismissed", + "source_run_id": 1, + "source_run_attempt": 2, + }, + "Reviewer Bot PR Review Dismissed Observer", + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-review-dismissed-context-1-attempt-2", + "deferred-review-dismissed.json", + ), + ( + { + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_run_id": 1, + "source_run_attempt": 2, + }, + "Reviewer Bot PR Review Comment Observer", + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-review-comment-context-1-attempt-2", + "deferred-review-comment.json", + ), + ], +) +def test_deferred_workflow_identity_helpers_match_expected_contract( + payload, + workflow_name, + workflow_file, + artifact_name, + payload_name, +): + assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( + workflow_name, + workflow_file, + ) + assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name + assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name + + +def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + + +def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="run_attempt mismatch"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + + +def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") + payload = { + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 1, + "source_run_attempt": 1, + } + + with pytest.raises(RuntimeError, match="did not conclude successfully"): + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) From 1f7fc94d67afecee00aaed292a4250073346f0eb Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 07:26:23 +0900 Subject: [PATCH 016/209] test(reviewer-bot): expand workflow file contracts --- .../reviewer_bot/test_workflow_files.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py index 80b409d0e..c77f449bf 100644 --- a/tests/contract/reviewer_bot/test_workflow_files.py +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -75,3 +75,45 @@ def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency( data = yaml.safe_load(Path(workflow_path).read_text(encoding="utf-8")) for job in data.get("jobs", {}).values(): assert "concurrency" not in job + + +def test_workflow_policy_split_and_lock_only_boundaries(): + workflows_dir = Path(".github/workflows") + required = { + "reviewer-bot-issues.yml", + "reviewer-bot-issue-comment-direct.yml", + "reviewer-bot-sweeper-repair.yml", + "reviewer-bot-pr-metadata.yml", + "reviewer-bot-pr-comment-trusted.yml", + "reviewer-bot-pr-comment-observer.yml", + "reviewer-bot-pr-review-submitted-observer.yml", + "reviewer-bot-pr-review-dismissed-observer.yml", + "reviewer-bot-pr-review-comment-observer.yml", + "reviewer-bot-reconcile.yml", + "reviewer-bot-privileged-commands.yml", + } + assert required.issubset({path.name for path in workflows_dir.glob("reviewer-bot-*.yml")}) + for path in required: + data = yaml.safe_load((workflows_dir / path).read_text(encoding="utf-8")) + jobs = data.get("jobs", {}) + for job in jobs.values(): + permissions = job.get("permissions", {}) + steps = job.get("steps", []) + uses_values = [step.get("uses", "") for step in steps if isinstance(step, dict)] + text = (workflows_dir / path).read_text(encoding="utf-8") + if "observer" in path: + assert permissions.get("contents") == "read" + assert all("checkout" not in value for value in uses_values) + if permissions.get("contents") == "write" and path != "reviewer-bot-privileged-commands.yml": + assert all("checkout" not in value for value in uses_values) + assert "Temporary lock debt" in text + for value in uses_values: + if value: + assert "@" in value and len(value.split("@", 1)[1]) == 40 + + +def test_workflow_summaries_and_runbook_references_exist(): + runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md") + assert runbook.exists() + reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") + assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile From 260f4ddc2d4e70a1e049c7fd693e8adbee1ada5e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 07:28:20 +0900 Subject: [PATCH 017/209] refactor(reviewer-bot): introduce thin runtime object --- scripts/reviewer_bot.py | 4 ++- scripts/reviewer_bot_lib/runtime.py | 27 +++++++++++++++++++ .../reviewer_bot/test_adapter_contract.py | 8 ++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 scripts/reviewer_bot_lib/runtime.py diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 12425d2e2..3c608920a 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -179,6 +179,7 @@ from scripts.reviewer_bot_lib.queue import ( sync_members_with_queue as queue_sync_members_with_queue, ) +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime requests = github_api_module.requests random = lease_lock_module.random @@ -191,10 +192,11 @@ ACTIVE_LEASE_CONTEXT: LeaseContext | None = None TOUCHED_ISSUE_NUMBERS: set[int] = set() +RUNTIME = ReviewerBotRuntime(sys.modules[__name__]) def _runtime_bot() -> Any: - return sys.modules[__name__] + return RUNTIME def get_github_token() -> str: diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py new file mode 100644 index 000000000..d79bcc673 --- /dev/null +++ b/scripts/reviewer_bot_lib/runtime.py @@ -0,0 +1,27 @@ +"""Concrete runtime object for reviewer-bot orchestration.""" + +from __future__ import annotations + +from types import ModuleType +from typing import Any + + +class ReviewerBotRuntime: + """Thin compatibility wrapper around the adapter module. + + The first slice is intentionally structural only: it delegates all runtime + reads and writes to the existing adapter module so current wrappers, + monkeypatching patterns, and mutable state remain behavior-compatible. + """ + + def __init__(self, module: ModuleType): + object.__setattr__(self, "_module", module) + + def __getattr__(self, name: str) -> Any: + return getattr(self._module, name) + + def __setattr__(self, name: str, value: Any) -> None: + if name == "_module": + object.__setattr__(self, name, value) + return + setattr(self._module, name, value) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 793714327..829029f01 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -7,6 +7,7 @@ ReviewerBotContext, StateStoreContext, ) +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime def test_reviewer_bot_exports_runtime_modules(): @@ -41,3 +42,10 @@ def test_runtime_context_protocol_exposes_structured_head_repair_contract(): hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + + +def test_runtime_bot_returns_concrete_runtime_object(): + runtime = reviewer_bot._runtime_bot() + + assert isinstance(runtime, ReviewerBotRuntime) + assert runtime.EVENT_INTENT_MUTATING == reviewer_bot.EVENT_INTENT_MUTATING From c3ca1299bcf26044f818026cf045e1e3c8c4004a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 07:35:05 +0900 Subject: [PATCH 018/209] refactor(reviewer-bot): add explicit app event context --- scripts/reviewer_bot.py | 9 ++ scripts/reviewer_bot_lib/app.py | 145 +++++++++++++++--- scripts/reviewer_bot_lib/context.py | 35 +++++ .../reviewer_bot/test_adapter_contract.py | 26 ++++ 4 files changed, 195 insertions(+), 20 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 3c608920a..73b528d66 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -158,6 +158,7 @@ StateIssueSnapshot, get_commands_help, ) +from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult # noqa: F401 from scripts.reviewer_bot_lib.guidance import ( # noqa: F401 get_fls_audit_guidance, get_issue_guidance, @@ -921,6 +922,14 @@ def event_requires_lease_lock(event_name: str, event_action: str) -> bool: return app_module.event_requires_lease_lock(_runtime_bot(), event_name, event_action) +def build_event_context() -> EventContext: + return app_module.build_event_context() + + +def execute_run(context: EventContext) -> ExecutionResult: + return app_module.execute_run(_runtime_bot(), context) + + def main(): app_module.main(_runtime_bot()) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 412647f1a..6b3ada105 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -1,9 +1,10 @@ """Top-level reviewer-bot orchestration.""" +import json import os import sys -from .context import ReviewerBotContext +from .context import EventContext, ExecutionResult, ReviewerBotContext from .maintenance import ( collect_status_projection_repair_items, status_projection_repair_needed, @@ -47,8 +48,74 @@ def _mark_projection_repair_needed(bot: ReviewerBotContext, state: dict, issue_n return changed -def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action: str) -> str: - """Classify whether a run can mutate reviewer-bot state.""" +def _parse_optional_int(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + +def _parse_optional_bool(value: str) -> bool | None: + value = value.strip().lower() + if not value: + return None + if value == "true": + return True + if value == "false": + return False + return None + + +def _parse_issue_labels(value: str) -> tuple[str, ...]: + value = value.strip() + if not value: + return () + try: + payload = json.loads(value) + except json.JSONDecodeError: + return () + if not isinstance(payload, list): + return () + return tuple(label for label in payload if isinstance(label, str)) + + +def build_event_context() -> EventContext: + return EventContext( + event_name=os.environ.get("EVENT_NAME", "").strip(), + event_action=os.environ.get("EVENT_ACTION", "").strip(), + issue_number=_parse_optional_int(os.environ.get("ISSUE_NUMBER", "")), + is_pull_request=_parse_optional_bool(os.environ.get("IS_PULL_REQUEST", "")), + issue_author=os.environ.get("ISSUE_AUTHOR", "").strip() or None, + issue_state=os.environ.get("ISSUE_STATE", "").strip() or None, + issue_labels=_parse_issue_labels(os.environ.get("ISSUE_LABELS", "")), + comment_id=_parse_optional_int(os.environ.get("COMMENT_ID", "")), + comment_author=os.environ.get("COMMENT_AUTHOR", "").strip() or None, + comment_body=os.environ.get("COMMENT_BODY", "") or None, + comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip() or None, + pr_is_cross_repository=_parse_optional_bool(os.environ.get("PR_IS_CROSS_REPOSITORY", "")), + review_author=os.environ.get("REVIEW_AUTHOR", "").strip() or None, + review_state=os.environ.get("REVIEW_STATE", "").strip() or None, + workflow_run_event=os.environ.get("WORKFLOW_RUN_EVENT", "").strip() or None, + workflow_run_event_action=os.environ.get("WORKFLOW_RUN_EVENT_ACTION", "").strip() or None, + workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip() or None, + workflow_run_reconcile_pr_number=_parse_optional_int( + os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "") + ), + workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip() or None, + workflow_run_id=_parse_optional_int(os.environ.get("WORKFLOW_RUN_ID", "")), + workflow_name=os.environ.get("WORKFLOW_NAME", "").strip() or None, + workflow_job_name=os.environ.get("WORKFLOW_JOB_NAME", "").strip() or None, + manual_action=os.environ.get("MANUAL_ACTION", "").strip() or None, + ) + + +def _classify_event_intent_from_context(bot: ReviewerBotContext, context: EventContext) -> str: + event_name = context.event_name + event_action = context.event_action + if event_name in {"issues", "pull_request_target"}: if event_action in {"opened", "labeled", "edited", "closed", "synchronize"}: return bot.EVENT_INTENT_MUTATING @@ -56,7 +123,7 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action if event_name == "issue_comment": if event_action == "created": - if os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": + if context.is_pull_request is True: trust_class = os.environ.get("REVIEWER_BOT_TRUST_CLASS", "").strip() if trust_class in {"pr_deferred_reconcile", "safe_noop"}: return bot.EVENT_INTENT_NON_MUTATING_DEFER @@ -76,14 +143,12 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action if event_name == "workflow_run": if event_action != "completed": return bot.EVENT_INTENT_NON_MUTATING_READONLY - workflow_run_event = os.environ.get("WORKFLOW_RUN_EVENT", "").strip() - if workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: + if context.workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: return bot.EVENT_INTENT_MUTATING return bot.EVENT_INTENT_NON_MUTATING_READONLY if event_name == "workflow_dispatch": - action = os.environ.get("MANUAL_ACTION", "").strip() - if action in {"show-state", "preview-reviewer-board"}: + if context.manual_action in {"show-state", "preview-reviewer-board"}: return bot.EVENT_INTENT_NON_MUTATING_READONLY return bot.EVENT_INTENT_MUTATING @@ -93,18 +158,48 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action return bot.EVENT_INTENT_NON_MUTATING_READONLY +def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action: str) -> str: + """Classify whether a run can mutate reviewer-bot state.""" + context = build_event_context() + context = EventContext( + event_name=event_name, + event_action=event_action, + issue_number=context.issue_number, + is_pull_request=context.is_pull_request, + issue_author=context.issue_author, + issue_state=context.issue_state, + issue_labels=context.issue_labels, + comment_id=context.comment_id, + comment_author=context.comment_author, + comment_body=context.comment_body, + comment_source_event_key=context.comment_source_event_key, + pr_is_cross_repository=context.pr_is_cross_repository, + review_author=context.review_author, + review_state=context.review_state, + workflow_run_event=context.workflow_run_event, + workflow_run_event_action=context.workflow_run_event_action, + workflow_run_head_sha=context.workflow_run_head_sha, + workflow_run_reconcile_pr_number=context.workflow_run_reconcile_pr_number, + workflow_run_reconcile_head_sha=context.workflow_run_reconcile_head_sha, + workflow_run_id=context.workflow_run_id, + workflow_name=context.workflow_name, + workflow_job_name=context.workflow_job_name, + manual_action=context.manual_action, + ) + return _classify_event_intent_from_context(bot, context) + + def event_requires_lease_lock(bot: ReviewerBotContext, event_name: str, event_action: str) -> bool: """Backwards-compatible helper for tests and call sites.""" return classify_event_intent(bot, event_name, event_action) == bot.EVENT_INTENT_MUTATING -def main(bot: ReviewerBotContext): - """Main entry point for the reviewer bot.""" - event_name = os.environ.get("EVENT_NAME", "") - event_action = os.environ.get("EVENT_ACTION", "") +def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResult: bot.drain_touched_items() - event_intent = classify_event_intent(bot, event_name, event_action) + event_name = context.event_name + event_action = context.event_action + event_intent = _classify_event_intent_from_context(bot, context) lock_required = event_intent == bot.EVENT_INTENT_MUTATING print( f"Event: {event_name}, Action: {event_action}, Intent: {event_intent}, " @@ -181,12 +276,12 @@ def main(bot: ReviewerBotContext): elif event_name == "workflow_run": if event_action == "completed": - if os.environ.get("WORKFLOW_RUN_EVENT", "").strip() in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: + if context.workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: state_changed = bot.handle_workflow_run_event(state) else: print( "Ignoring workflow_run event with unsupported source event: " - f"{os.environ.get('WORKFLOW_RUN_EVENT', '').strip() or ''}" + f"{context.workflow_run_event or ''}" ) touched_items = bot.drain_touched_items() @@ -266,11 +361,11 @@ def main(bot: ReviewerBotContext): "Status projection epoch repair succeeded but could not be persisted." ) + execution_state_changed = bool(state_changed or sync_changes or restored or status_labels_changed) + with open(os.environ.get("GITHUB_OUTPUT", "/dev/null"), "a") as output_file: output_file.write( - "state_changed=true\n" - if (state_changed or bool(sync_changes) or bool(restored) or status_labels_changed) - else "state_changed=false\n" + "state_changed=true\n" if execution_state_changed else "state_changed=false\n" ) if projection_failure is not None: print( @@ -296,5 +391,15 @@ def main(bot: ReviewerBotContext): ) exit_code = 1 - if exit_code: - sys.exit(exit_code) + return ExecutionResult( + exit_code=exit_code, + state_changed=bool(state_changed or sync_changes or restored or status_labels_changed), + release_failed=release_failed, + ) + + +def main(bot: ReviewerBotContext): + """Main entry point for the reviewer bot.""" + result = execute_run(bot, build_event_context()) + if result.exit_code: + sys.exit(result.exit_code) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 76936b030..7e38d4af5 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from datetime import datetime from typing import Any, Protocol, runtime_checkable @@ -10,6 +11,40 @@ from .lifecycle import HeadObservationRepairResult +@dataclass(frozen=True) +class EventContext: + event_name: str + event_action: str + issue_number: int | None = None + is_pull_request: bool | None = None + issue_author: str | None = None + issue_state: str | None = None + issue_labels: tuple[str, ...] = () + comment_id: int | None = None + comment_author: str | None = None + comment_body: str | None = None + comment_source_event_key: str | None = None + pr_is_cross_repository: bool | None = None + review_author: str | None = None + review_state: str | None = None + workflow_run_event: str | None = None + workflow_run_event_action: str | None = None + workflow_run_head_sha: str | None = None + workflow_run_reconcile_pr_number: int | None = None + workflow_run_reconcile_head_sha: str | None = None + workflow_run_id: int | None = None + workflow_name: str | None = None + workflow_job_name: str | None = None + manual_action: str | None = None + + +@dataclass(frozen=True) +class ExecutionResult: + exit_code: int + state_changed: bool + release_failed: bool = False + + @runtime_checkable class GitHubTransportContext(Protocol): """GitHub API transport and mutation surface used by low-level helpers.""" diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 829029f01..d0cea9b3d 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -49,3 +49,29 @@ def test_runtime_bot_returns_concrete_runtime_object(): assert isinstance(runtime, ReviewerBotRuntime) assert runtime.EVENT_INTENT_MUTATING == reviewer_bot.EVENT_INTENT_MUTATING + + +def test_build_event_context_returns_structured_context(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + monkeypatch.setenv("ISSUE_LABELS", '["coding guideline"]') + + context = reviewer_bot.build_event_context() + + assert isinstance(context, reviewer_bot.EventContext) + assert context.event_name == "workflow_run" + assert context.workflow_run_event == "pull_request_review" + assert context.issue_labels == ("coding guideline",) + + +def test_execute_run_returns_execution_result(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "pull_request_review") + monkeypatch.setenv("EVENT_ACTION", "submitted") + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: {"active_reviews": {}}) + monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert isinstance(result, reviewer_bot.ExecutionResult) + assert result.exit_code == 0 From 2fcf272040761c6c2d2c87ab64c5d4a2692823f4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 07:36:25 +0900 Subject: [PATCH 019/209] test(reviewer-bot): extract app event intent tests --- .../reviewer_bot/test_app_event_intent.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_app_event_intent.py diff --git a/tests/unit/reviewer_bot/test_app_event_intent.py b/tests/unit/reviewer_bot/test_app_event_intent.py new file mode 100644 index 000000000..8a4ce314c --- /dev/null +++ b/tests/unit/reviewer_bot/test_app_event_intent.py @@ -0,0 +1,42 @@ +from scripts import reviewer_bot + + +def test_classify_event_intent_cross_repo_review_is_non_mutating_defer(monkeypatch): + monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") + intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") + assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + + +def test_classify_event_intent_preview_reviewer_board_is_non_mutating(monkeypatch): + monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") + intent = reviewer_bot.classify_event_intent("workflow_dispatch", "") + assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY + + +def test_classify_event_intent_same_repo_review_is_non_mutating_defer(monkeypatch): + intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") + assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + + +def test_classify_event_intent_same_repo_dismissed_review_is_non_mutating_defer(monkeypatch): + intent = reviewer_bot.classify_event_intent("pull_request_review", "dismissed") + assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + + +def test_classify_event_intent_review_comment_is_non_mutating_defer(monkeypatch): + intent = reviewer_bot.classify_event_intent("pull_request_review_comment", "created") + assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + + +def test_classify_event_intent_workflow_run_dismissed_review_is_mutating(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "dismissed") + intent = reviewer_bot.classify_event_intent("workflow_run", "completed") + assert intent == reviewer_bot.EVENT_INTENT_MUTATING + + +def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") + assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING From bcd7dc3d968f39c75f2b942343de401a25915db5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:08:22 +0900 Subject: [PATCH 020/209] test(reviewer-bot): extract app workflow path tests --- .../test_app_workflow_run_paths.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_workflow_run_paths.py diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py new file mode 100644 index 000000000..1def37964 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -0,0 +1,105 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_cross_repo_review_does_not_acquire_lock(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "pull_request_review") + monkeypatch.setenv("EVENT_ACTION", "submitted") + monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") + + acquire_called = {"value": False} + + def fail_if_called(): + acquire_called["value"] = True + raise AssertionError("acquire_state_issue_lease_lock should not be called") + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + + reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert acquire_called["value"] is False + + +def test_execute_run_same_repo_review_does_not_acquire_lock(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "pull_request_review") + monkeypatch.setenv("EVENT_ACTION", "submitted") + + acquire_called = {"value": False} + + def fail_if_called(): + acquire_called["value"] = True + raise AssertionError("acquire_state_issue_lease_lock should not be called") + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + + reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert acquire_called["value"] is False + + +def test_execute_run_workflow_run_reconcile_acquires_lock(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + + acquire_called = {"value": False} + + def fake_acquire(): + acquire_called["value"] = True + return reviewer_bot.LeaseContext( + lock_token="token", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) + + reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert acquire_called["value"] is True + + +def test_execute_run_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + + acquire_called = {"value": False} + + def fake_acquire(): + acquire_called["value"] = True + return reviewer_bot.LeaseContext( + lock_token="token", + lock_owner_run_id="run", + lock_owner_workflow="workflow", + lock_owner_job="job", + state_issue_url="https://example.com/issues/314", + lock_ref="refs/heads/reviewer-bot-state-lock", + lock_expires_at="2999-01-01T00:00:00+00:00", + ) + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) + + reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert acquire_called["value"] is True From 51d2b85a59a64034a64596f4c8b9b39aa7feed67 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:10:50 +0900 Subject: [PATCH 021/209] test(reviewer-bot): extract app execution tests --- .../reviewer_bot/test_app_execution.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_execution.py diff --git a/tests/integration/reviewer_bot/test_app_execution.py b/tests/integration/reviewer_bot/test_app_execution.py new file mode 100644 index 000000000..9e3e2aed7 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -0,0 +1,98 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_reloads_state_before_syncing_status_labels(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + + initial_state = make_state() + reloaded_state = make_state() + load_calls = {"count": 0} + call_order = [] + + def fake_load_state(*, fail_on_unavailable=False): + load_calls["count"] += 1 + call_order.append(f"load:{load_calls['count']}") + if load_calls["count"] == 1: + return initial_state + return reloaded_state + + def fake_handle_comment_event(state): + assert state is initial_state + reviewer_bot.collect_touched_item(42) + call_order.append("handle") + return True + + def fake_save_state(state): + assert state is initial_state + call_order.append("save") + return True + + def fake_sync_status_labels_for_items(state, issue_numbers): + call_order.append("sync") + assert state is reloaded_state + assert list(issue_numbers) == [42] + return True + + 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", fake_load_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "handle_comment_event", fake_handle_comment_event) + monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", fake_sync_status_labels_for_items) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert result.state_changed is True + assert call_order == [ + "load:1", + "handle", + "load:2", + "save", + "load:3", + "load:4", + "sync", + ] + + +def test_execute_run_returns_failure_when_save_state_fails(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + 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: make_state()) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "handle_comment_event", lambda state: True) + monkeypatch.setattr(reviewer_bot, "save_state", lambda state: False) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert result.state_changed is True + + +def test_execute_run_returns_failure_for_invalid_workflow_run_context(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") + 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: make_state()) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr( + reviewer_bot, + "handle_workflow_run_event", + lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context")), + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert result.state_changed is False From 38ad2a084b78e90337a08e2bd8ba43993dc20f15 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:13:11 +0900 Subject: [PATCH 022/209] test(reviewer-bot): extract workflow bookkeeping tests --- .../test_app_workflow_run_bookkeeping.py | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py new file mode 100644 index 000000000..9c5d240cd --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -0,0 +1,275 @@ +import json + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "bob" + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 700, + "source_run_attempt": 1, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "700") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + 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.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/11": + return { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "pull_request_review:11" in current["active_reviews"]["42"]["deferred_gaps"], + } + ) + or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] + assert synced_issue_numbers == [42] + + +def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 710, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /queue"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "710") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + + 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.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/210": + return { + "body": "@guidelines-bot /queue", + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "issue_comment:210" in current["active_reviews"]["42"]["deferred_gaps"], + } + ) + or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] + + +def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("EVENT_NAME", "workflow_run") + monkeypatch.setenv("EVENT_ACTION", "completed") + monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["pull_request_review_comment:310"] = {"reason": "artifact_missing"} + + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 711, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:310", + "pr_number": 42, + "comment_id": 310, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": reviewer_bot.comment_routing_module._digest_body("review comment body"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "711") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + save_snapshots = [] + + 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, [])) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/310": + return { + "body": "review comment body", + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + "created_at": "2026-03-17T10:00:00Z", + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_snapshots.append( + { + "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), + "gap_present": "pull_request_review_comment:310" + in current["active_reviews"]["42"]["deferred_gaps"], + } + ) + or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_snapshots == [ + {"reconciled": ["pull_request_review_comment:310"], "gap_present": False} + ] From d761e89f4463e2ad51c40078b1611632633a527e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:14:42 +0900 Subject: [PATCH 023/209] test(reviewer-bot): extract reviewer board preview tests --- .../test_app_reviewer_board_preview.py | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_reviewer_board_preview.py diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py new file mode 100644 index 000000000..cfaf49624 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -0,0 +1,166 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata + + +def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): + monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") + monkeypatch.setenv("EVENT_ACTION", "") + monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") + monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "false") + + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr( + reviewer_bot, + "acquire_state_issue_lease_lock", + lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), + ) + monkeypatch.setattr( + reviewer_bot, + "process_pass_until_expirations", + lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), + ) + monkeypatch.setattr( + reviewer_bot, + "sync_members_with_queue", + lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state")), + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + output = capsys.readouterr().out + assert "Reviewer board preview skipped: reviewer board is disabled." in output + + +def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, capsys): + monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") + monkeypatch.setenv("EVENT_ACTION", "") + monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") + monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr( + reviewer_bot, + "acquire_state_issue_lease_lock", + lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), + ) + monkeypatch.setattr( + reviewer_bot, + "process_pass_until_expirations", + lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), + ) + monkeypatch.setattr( + reviewer_bot, + "sync_members_with_queue", + lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err + + +def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): + monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") + monkeypatch.setenv("EVENT_ACTION", "") + monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") + monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") + monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + monkeypatch.setattr( + reviewer_bot, + "acquire_state_issue_lease_lock", + lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), + ) + monkeypatch.setattr( + reviewer_bot, + "github_graphql", + lambda query, variables=None, *, token=None: { + "data": { + "organization": { + "projectV2": { + "id": "PVT_kwDOB", + "title": "Reviewer Board", + "fields": {"nodes": []}, + } + } + } + }, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert "Missing reviewer board field: Review State" in capsys.readouterr().err + + +def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): + monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") + monkeypatch.setenv("EVENT_ACTION", "") + monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") + monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") + monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + + 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" + review["assigned_at"] = "2026-03-20T12:34:56Z" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) + monkeypatch.setattr( + reviewer_bot, + "acquire_state_issue_lease_lock", + lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), + ) + monkeypatch.setattr( + reviewer_bot, + "process_pass_until_expirations", + lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), + ) + monkeypatch.setattr( + reviewer_bot, + "sync_members_with_queue", + lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state")), + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), + ) + monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + output = capsys.readouterr().out + assert "classification: open_tracked_assigned" in output + assert "ensure_membership: true" in output From 362c3df1b8b77439e4ec08510c3baac63ee2778a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:16:21 +0900 Subject: [PATCH 024/209] test(reviewer-bot): extract app schedule repair tests --- .../reviewer_bot/test_app_schedule_repairs.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_schedule_repairs.py diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py new file mode 100644 index 000000000..fd6262739 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -0,0 +1,126 @@ +import json + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_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, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert synced_issue_numbers == [42, 99] + assert saved_epochs[-1] == reviewer_bot.STATUS_PROJECTION_EPOCH + + +def test_execute_run_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, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert all(epoch != reviewer_bot.STATUS_PROJECTION_EPOCH for epoch in saved_epochs) + + +def test_execute_run_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "plain text") + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + saved_states = [] + + def fake_load_state(*, fail_on_unavailable=False): + return json.loads(json.dumps(state)) + + def fake_save_state(updated_state): + saved_states.append(json.loads(json.dumps(updated_state))) + state.clear() + state.update(json.loads(json.dumps(updated_state))) + return True + + monkeypatch.setattr(reviewer_bot, "load_state", fake_load_state) + monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current_state: (current_state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current_state: (current_state, [])) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed")), + ) + output_path = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" + assert len(saved_states) >= 2 From 0e36f43d1c7d1acd7ffc2238752029acfb20354a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:18:37 +0900 Subject: [PATCH 025/209] test(reviewer-bot): extract closed issue cleanup tests --- .../test_app_closed_issue_cleanup.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py diff --git a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py new file mode 100644 index 000000000..aa661ca95 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -0,0 +1,95 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + + initial_state = make_state() + review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + reloaded_state = make_state() + load_calls = {"count": 0} + save_calls = [] + sync_calls = [] + + def fake_load_state(*, fail_on_unavailable=False): + load_calls["count"] += 1 + if load_calls["count"] == 1: + return initial_state + return reloaded_state + + 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", fake_load_state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda state: save_calls.append("42" in state["active_reviews"]) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_calls == [False] + assert len(sync_calls) == 1 + assert sync_calls[0][0] is reloaded_state + assert sync_calls[0][1] == [42] + + +def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setenv("ISSUE_NUMBER", "42") + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_STATE", "closed") + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + + state = make_state() + save_called = {"value": False} + sync_calls = [] + + 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, + "save_state", + lambda current: save_called.__setitem__("value", True) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False, + ) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_called["value"] is False + assert sync_calls == [[42]] From 0eced7751f5bfa2bd87bd3a48420971a6eb6ae58 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:22:36 +0900 Subject: [PATCH 026/209] test(reviewer-bot): extract schedule bookkeeping tests --- .../test_app_schedule_bookkeeping.py | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py diff --git a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py new file mode 100644 index 000000000..f7b54203b --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -0,0 +1,162 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + save_calls = [] + + def fake_sweep(bot, current): + current["active_reviews"]["42"].setdefault("reconciled_source_events", []).append( + "pull_request_review:500" + ) + return True + + 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.maintenance_module, "sweep_deferred_gaps", fake_sweep) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append(list(current["active_reviews"]["42"]["reconciled_source_events"])) + or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_calls == [["pull_request_review:500"]] + + +def test_execute_run_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + + save_calls = [] + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}}, + {}, + "ok", + True, + None, + 0, + None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + 200, + [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ) + raise AssertionError(endpoint) + + 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) + monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: save_calls.append( + { + "last_reviewer_activity": current["active_reviews"]["42"]["last_reviewer_activity"], + "transition_warning_sent": current["active_reviews"]["42"]["transition_warning_sent"], + "transition_notice_sent_at": current["active_reviews"]["42"]["transition_notice_sent_at"], + } + ) + or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 0 + assert save_calls == [ + { + "last_reviewer_activity": "2026-03-17T10:01:00Z", + "transition_warning_sent": None, + "transition_notice_sent_at": None, + } + ] From 439a567ee5e421aa20759dfc8990c341a04edaf0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:27:54 +0900 Subject: [PATCH 027/209] test(reviewer-bot): extract state unavailable tests --- .../test_app_state_unavailable.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_app_state_unavailable.py diff --git a/tests/integration/reviewer_bot/test_app_state_unavailable.py b/tests/integration/reviewer_bot/test_app_state_unavailable.py new file mode 100644 index 000000000..dc1f294ae --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_state_unavailable.py @@ -0,0 +1,69 @@ +from scripts import reviewer_bot + + +def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + + def fail_load(*, fail_on_unavailable=False): + assert fail_on_unavailable is True + raise RuntimeError("state unavailable") + + monkeypatch.setattr(reviewer_bot, "load_state", fail_load) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert result.state_changed is False + + +def test_execute_run_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "issue_comment") + monkeypatch.setenv("EVENT_ACTION", "created") + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + + called = { + "pass_until": False, + "sync": False, + "handler": False, + "save": False, + } + + def fail_load(*, fail_on_unavailable=False): + assert fail_on_unavailable is True + raise RuntimeError("state unavailable") + + def track_pass_until(state): + called["pass_until"] = True + return state, [] + + def track_sync(state): + called["sync"] = True + return state, [] + + def track_handler(state): + called["handler"] = True + return True + + def track_save(state): + called["save"] = True + return True + + monkeypatch.setattr(reviewer_bot, "load_state", fail_load) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", track_pass_until) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", track_sync) + monkeypatch.setattr(reviewer_bot, "handle_comment_event", track_handler) + monkeypatch.setattr(reviewer_bot, "save_state", track_save) + + result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert result.exit_code == 1 + assert called == { + "pass_until": False, + "sync": False, + "handler": False, + "save": False, + } From a65a2554541f83f4c0c4500abc5e4995d17dbed2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:31:35 +0900 Subject: [PATCH 028/209] refactor(reviewer-bot): extract reviews projection helpers --- scripts/reviewer_bot_lib/reviews.py | 94 ++++---------- .../reviewer_bot_lib/reviews_projection.py | 103 +++++++++++++++ .../reviewer_bot/test_reviews_projection.py | 117 ++++++++++++++++++ 3 files changed, 246 insertions(+), 68 deletions(-) create mode 100644 scripts/reviewer_bot_lib/reviews_projection.py create mode 100644 tests/unit/reviewer_bot/test_reviews_projection.py diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index b1ee686e4..27b62b617 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -10,11 +10,13 @@ MANDATORY_TRIAGE_APPROVER_LABEL, MANDATORY_TRIAGE_ESCALATION_TEMPLATE, MANDATORY_TRIAGE_SATISFIED_TEMPLATE, - STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, - STATUS_AWAITING_REVIEWER_RESPONSE_LABEL, - STATUS_AWAITING_WRITE_APPROVAL_LABEL, STATUS_LABELS, ) +from .reviews_projection import ( + compute_pr_approval_state_from_reviews, + desired_labels_from_response_state, + filter_current_head_reviews_for_cycle, +) def _now_iso() -> str: @@ -341,72 +343,36 @@ def compute_pr_approval_state_result( return reviews_result reviews = reviews_result["reviews"] - survivors: dict[str, dict] = {} + normalized_reviews = [] for review in reviews: if not isinstance(review, dict): + normalized_reviews.append(review) continue - state = str(review.get("state", "")).upper() - if state == "DISMISSED": - continue - submitted_at = parse_github_timestamp(review.get("submitted_at")) - if submitted_at is None or submitted_at < boundary: - continue - commit_id = review.get("commit_id") - if not isinstance(commit_id, str) or not commit_id.strip(): - continue - if commit_id.strip() != current_head: - continue - author = review.get("user", {}).get("login") - if not isinstance(author, str) or not author.strip(): - continue - review_id = str(review.get("id", "")) - key = author.lower() - candidate_key = (submitted_at, review_id) - current = survivors.get(key) - if current is None: - survivors[key] = review - continue - current_key = ( - parse_github_timestamp(current.get("submitted_at")) - or datetime.min.replace(tzinfo=timezone.utc), - str(current.get("id", "")), - ) - if candidate_key >= current_key: - survivors[key] = review - - approvals = [review for review in survivors.values() if str(review.get("state", "")).upper() == "APPROVED"] - completion = { - "completed": bool(approvals), - "current_head_sha": current_head, - "qualifying_review_ids": [review.get("id") for review in approvals], - } - + normalized = dict(review) + normalized["submitted_at"] = parse_github_timestamp(review.get("submitted_at")) + normalized_reviews.append(normalized) + + survivors = filter_current_head_reviews_for_cycle( + normalized_reviews, + boundary=boundary, + current_head=current_head, + ) permission_cache: dict[str, str] = {} - has_write_approval = False - write_approvers: list[str] = [] - for review in approvals: + for review in survivors.values(): author = review.get("user", {}).get("login") if not isinstance(author, str) or not author.strip(): continue cache_key = author.lower() if cache_key not in permission_cache: permission_cache[cache_key] = _permission_status(bot, author, "push") - if permission_cache[cache_key] == "unavailable": - return _projection_failure("permission_unavailable") - if permission_cache[cache_key] == "granted": - has_write_approval = True - write_approvers.append(author) - write_approval = { - "has_write_approval": has_write_approval, - "write_approvers": write_approvers, - "current_head_sha": current_head, - } - return { - "ok": True, - "completion": completion, - "write_approval": write_approval, - "current_head_sha": current_head, - } + result = compute_pr_approval_state_from_reviews( + survivors, + current_head=current_head, + permission_statuses=permission_cache, + ) + if not result.get("ok"): + return _projection_failure(str(result.get("reason"))) + return result def apply_pr_approval_state( @@ -1116,15 +1082,7 @@ def project_status_labels_for_item( response_state = compute_reviewer_response_state(bot, issue_number, review_data, issue_snapshot=issue_snapshot) state_name = response_state.get("state") reason = response_state.get("reason") - if state_name == "projection_failed": - return None, {"state": str(state_name), "reason": str(reason)} - if state_name == "awaiting_reviewer_response": - return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": str(state_name), "reason": str(reason)}) - if state_name == "awaiting_contributor_response": - return ({STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL}, {"state": str(state_name), "reason": str(reason)}) - if state_name == "awaiting_write_approval": - return ({STATUS_AWAITING_WRITE_APPROVAL_LABEL}, {"state": str(state_name), "reason": str(reason)}) - return set(), {"state": str(state_name), "reason": None if reason is None else str(reason)} + return desired_labels_from_response_state(str(state_name), None if reason is None else str(reason)) def sync_status_labels(bot, issue_number: int, desired_labels: set[str], actual_labels: Iterable[str]) -> bool: diff --git a/scripts/reviewer_bot_lib/reviews_projection.py b/scripts/reviewer_bot_lib/reviews_projection.py new file mode 100644 index 000000000..cdf10af7d --- /dev/null +++ b/scripts/reviewer_bot_lib/reviews_projection.py @@ -0,0 +1,103 @@ +"""Pure projection helpers for reviewer-bot review state.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from .config import ( + STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, + STATUS_AWAITING_REVIEWER_RESPONSE_LABEL, + STATUS_AWAITING_WRITE_APPROVAL_LABEL, +) + + +def filter_current_head_reviews_for_cycle( + reviews: list[dict], + *, + boundary: datetime, + current_head: str, +) -> dict[str, dict]: + survivors: dict[str, dict] = {} + for review in reviews: + if not isinstance(review, dict): + continue + state = str(review.get("state", "")).upper() + if state == "DISMISSED": + continue + submitted_at = review.get("submitted_at") + if not isinstance(submitted_at, datetime) or submitted_at < boundary: + continue + commit_id = review.get("commit_id") + if not isinstance(commit_id, str) or not commit_id.strip() or commit_id.strip() != current_head: + continue + author = review.get("user", {}).get("login") + if not isinstance(author, str) or not author.strip(): + continue + review_id = str(review.get("id", "")) + key = author.lower() + candidate_key = (submitted_at, review_id) + current = survivors.get(key) + if current is None: + survivors[key] = review + continue + current_key = ( + current.get("submitted_at") or datetime.min.replace(tzinfo=timezone.utc), + str(current.get("id", "")), + ) + if candidate_key >= current_key: + survivors[key] = review + return survivors + + +def compute_pr_approval_state_from_reviews( + survivors: dict[str, dict], + *, + current_head: str, + permission_statuses: dict[str, str], +) -> dict[str, object]: + approvals = [review for review in survivors.values() if str(review.get("state", "")).upper() == "APPROVED"] + completion = { + "completed": bool(approvals), + "current_head_sha": current_head, + "qualifying_review_ids": [review.get("id") for review in approvals], + } + + has_write_approval = False + write_approvers: list[str] = [] + for review in approvals: + author = review.get("user", {}).get("login") + if not isinstance(author, str) or not author.strip(): + continue + status = permission_statuses.get(author.lower(), "unavailable") + if status == "unavailable": + return {"ok": False, "reason": "permission_unavailable"} + if status == "granted": + has_write_approval = True + write_approvers.append(author) + + write_approval = { + "has_write_approval": has_write_approval, + "write_approvers": write_approvers, + "current_head_sha": current_head, + } + return { + "ok": True, + "completion": completion, + "write_approval": write_approval, + "current_head_sha": current_head, + } + + +def desired_labels_from_response_state( + state_name: str, + reason: str | None, +) -> tuple[set[str] | None, dict[str, str | None]]: + if state_name == "projection_failed": + return None, {"state": state_name, "reason": reason} + if state_name == "awaiting_reviewer_response": + return {STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": state_name, "reason": reason} + if state_name == "awaiting_contributor_response": + return {STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL}, {"state": state_name, "reason": reason} + if state_name == "awaiting_write_approval": + return {STATUS_AWAITING_WRITE_APPROVAL_LABEL}, {"state": state_name, "reason": reason} + return set(), {"state": state_name, "reason": reason} diff --git a/tests/unit/reviewer_bot/test_reviews_projection.py b/tests/unit/reviewer_bot/test_reviews_projection.py new file mode 100644 index 000000000..845b5c427 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -0,0 +1,117 @@ +import json + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import reviews_projection +from tests.fixtures.reviewer_bot import make_state + + +def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_reviewer_response" + assert review == before + + +def test_compute_pr_approval_state_result_is_pure(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + before = json.loads(json.dumps(review)) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + + result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) + + assert result["ok"] is True + assert result["completion"]["completed"] is True + assert review == before + + +def test_apply_pr_approval_state_mutates_expected_fields(): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + + reviewer_bot.reviews_module.apply_pr_approval_state( + review, + completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, + write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, + current_head_sha="head-1", + ) + + assert review["active_head_sha"] == "head-1" + assert review["current_cycle_completion"]["completed"] is True + assert review["current_cycle_write_approval"]["has_write_approval"] is True + assert review["review_completion_source"] == "live_review_rebuild" + + +def test_compute_pr_approval_state_from_reviews_is_pure(): + survivors = { + "alice": { + "id": 10, + "state": "APPROVED", + "submitted_at": reviewer_bot.parse_github_timestamp("2026-03-17T10:01:00Z"), + "commit_id": "head-1", + "user": {"login": "alice"}, + } + } + before = json.loads(json.dumps({"survivors": {"alice": {"id": 10, "state": "APPROVED", "commit_id": "head-1", "user": {"login": "alice"}}}})) + + result = reviews_projection.compute_pr_approval_state_from_reviews( + survivors, + current_head="head-1", + permission_statuses={"alice": "granted"}, + ) + + assert result["ok"] is True + assert result["completion"]["completed"] is True + assert before["survivors"]["alice"]["id"] == 10 From 062db96c957a0fc75f326d77e4abeeac66abc3d6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:32:53 +0900 Subject: [PATCH 029/209] test(reviewer-bot): extract reviews api contract tests --- .../reviewer_bot/test_reviews_api_contract.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_reviews_api_contract.py diff --git a/tests/unit/reviewer_bot/test_reviews_api_contract.py b/tests/unit/reviewer_bot/test_reviews_api_contract.py new file mode 100644 index 000000000..a03c1b171 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -0,0 +1,95 @@ +import pytest + +from scripts import reviewer_bot + + +def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="server_error"): + reviewer_bot.list_open_items_with_status_labels() + + +def test_get_pull_request_reviews_result_paginates(monkeypatch): + responses = { + "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult( + 200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None + ), + "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult( + 200, [{"id": 100}], {}, "ok", True, None, 0, None + ), + } + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result["ok"] is True + assert len(result["reviews"]) == 101 + + +def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): + monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": True, "reviews": [{"id": 10}]} + + +def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, {"not": "a list"}, {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} + + +def test_pull_request_read_result_reports_not_found(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} + + +def test_pull_request_read_result_reports_invalid_payload(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None + ), + ) + + result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + + assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} From 0b1b9946c5371a2230ac110b064c325efa726ef4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 08:38:07 +0900 Subject: [PATCH 030/209] test(reviewer-bot): extract reviews live fetch tests --- .../reviewer_bot/test_reviews_live_fetch.py | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tests/unit/reviewer_bot/test_reviews_live_fetch.py diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py new file mode 100644 index 000000000..808063cb2 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -0,0 +1,254 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + + +def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_contributor_response" + assert response_state["reason"] == "completion_missing" + assert response_state["reviewer_review"]["semantic_key"] == "pull_request_review:10" + assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" + + +def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + + +def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:00:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" + assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] + + +def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( + reviewer_bot, + 42, + review, + ) + + assert changed is True + assert preferred_review is not None + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None From 4b00e446930c902d0c77737f20b9714ce9c91529 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:11:28 +0900 Subject: [PATCH 031/209] test(reviewer-bot): expand reviews live fetch tests --- .../reviewer_bot/test_reviews_live_fetch.py | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index 808063cb2..d68275e8e 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -252,3 +252,389 @@ def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_act assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" assert review["transition_warning_sent"] is None assert review["transition_notice_sent_at"] is None + + +def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "review_head_stale" + + +def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_revision", + semantic_key="pull_request_sync:42:head-1", + timestamp="2026-03-17T12:00:00Z", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 99, + "state": "COMMENTED", + "submitted_at": "2026-03-17T11:00:00Z", + "commit_id": "head-0", + "user": {"login": "alice"}, + } + ], + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "awaiting_reviewer_response" + assert response_state["reason"] == "contributor_revision_newer" + + +def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "vccjgust" + review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-02-18T09:30:00Z", + actor="dana", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 30, + "state": "COMMENTED", + "submitted_at": "2026-02-18T10:00:00Z", + "commit_id": "head-older", + "user": {"login": "vccjgust"}, + }, + { + "id": 31, + "state": "COMMENTED", + "submitted_at": "2026-02-18T11:00:00Z", + "commit_id": "head-current", + "user": {"login": "vccjgust"}, + }, + ], + ) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert metadata["reason"] == "completion_missing" + + +def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "contributor_comment", + semantic_key="issue_comment:20", + timestamp="2026-03-17T10:05:00Z", + actor="bob", + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert metadata["reason"] == "contributor_comment_newer" + + +def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:1", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "bob"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + + desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + + assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert metadata["state"] == "awaiting_write_approval" + review["mandatory_approver_required"] = True + desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) + assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + + +def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_unavailable" + + +def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {}}, + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "pull_request_head_unavailable" + + +def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + source_precedence=1, + ) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint.startswith("pulls/42/reviews"): + return reviewer_bot.GitHubApiResult( + status_code=200, + payload=[ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + + response_state = reviewer_bot.compute_reviewer_response_state(42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "permission_unavailable" From ed8c2b33388635005238d8a9700b79f4b62c0a3e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:16:15 +0900 Subject: [PATCH 032/209] refactor(reviewer-bot): mark canonical reviews entrypoints --- scripts/reviewer_bot.py | 3 +++ scripts/reviewer_bot_lib/reviews.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 73b528d66..42b0d1cc8 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -755,6 +755,9 @@ def get_pull_request_reviews(issue_number: int) -> list[dict] | None: return reviews_module.get_pull_request_reviews(_runtime_bot(), issue_number) +get_pull_request_reviews._reviewer_bot_canonical = True + + def compute_reviewer_response_state( issue_number: int, review_data: dict, diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 27b62b617..e3499702e 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -19,6 +19,15 @@ ) +def _mark_canonical(func): + setattr(func, "_reviewer_bot_canonical", True) + return func + + +def _is_canonical_callable(func) -> bool: + return callable(func) and bool(getattr(func, "_reviewer_bot_canonical", False)) + + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -62,7 +71,7 @@ def get_pull_request_reviews_result(bot, issue_number: int, reviews: list[dict] ) except SystemExit: fallback_loader = getattr(bot, "get_pull_request_reviews", None) - if callable(fallback_loader) and getattr(fallback_loader, "__name__", "") != "get_pull_request_reviews": + if callable(fallback_loader) and not _is_canonical_callable(fallback_loader): fallback_reviews = fallback_loader(issue_number) if not isinstance(fallback_reviews, list): return _projection_failure("reviews_unavailable") @@ -752,6 +761,7 @@ def get_current_cycle_boundary(bot, review_data: dict) -> datetime | None: return None +@_mark_canonical def rebuild_pr_approval_state( bot, issue_number: int, @@ -1002,7 +1012,7 @@ def compute_reviewer_response_state( "contributor_handoff": contributor_handoff, } - if getattr(bot.reviews_module.rebuild_pr_approval_state, "__name__", "") != "rebuild_pr_approval_state": + if not _is_canonical_callable(bot.reviews_module.rebuild_pr_approval_state): completion, write_approval = bot.reviews_module.rebuild_pr_approval_state( bot, issue_number, From 63028e8645c1f793c8a6032644c25fe8558df6df Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:19:57 +0900 Subject: [PATCH 033/209] refactor(reviewer-bot): isolate reviews live read helpers --- scripts/reviewer_bot_lib/reviews.py | 112 ++++++++++++++++++---------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index e3499702e..d36704466 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -36,6 +36,13 @@ def _projection_failure(reason: str, failure_kind: str | None = None) -> dict[st return {"ok": False, "reason": reason, "failure_kind": failure_kind} +def _fallback_pull_request_payload(bot, issue_number: int) -> dict[str, object]: + payload = bot.github_api("GET", f"pulls/{issue_number}") + if isinstance(payload, dict): + return {"ok": True, "pull_request": payload} + return _projection_failure("pull_request_unavailable") + + def _pull_request_read_result(bot, issue_number: int, pull_request: dict | None = None) -> dict[str, object]: if pull_request is not None: if isinstance(pull_request, dict): @@ -44,10 +51,7 @@ def _pull_request_read_result(bot, issue_number: int, pull_request: dict | None try: response = bot.github_api_request("GET", f"pulls/{issue_number}", retry_policy="idempotent_read") except SystemExit: - payload = bot.github_api("GET", f"pulls/{issue_number}") - if isinstance(payload, dict): - return {"ok": True, "pull_request": payload} - return _projection_failure("pull_request_unavailable") + return _fallback_pull_request_payload(bot, issue_number) if not response.ok: if response.failure_kind == "not_found": return _projection_failure("pull_request_not_found", response.failure_kind) @@ -57,6 +61,27 @@ def _pull_request_read_result(bot, issue_number: int, pull_request: dict | None return {"ok": True, "pull_request": response.payload} +def _fallback_pull_request_reviews_result(bot, issue_number: int) -> dict[str, object]: + fallback_loader = getattr(bot, "get_pull_request_reviews", None) + if callable(fallback_loader) and not _is_canonical_callable(fallback_loader): + fallback_reviews = fallback_loader(issue_number) + if not isinstance(fallback_reviews, list): + return _projection_failure("reviews_unavailable") + return {"ok": True, "reviews": fallback_reviews} + + collected_reviews: list[dict] = [] + page = 1 + while True: + payload = bot.github_api("GET", f"pulls/{issue_number}/reviews?per_page=100&page={page}") + if not isinstance(payload, list): + return _projection_failure("reviews_unavailable") + page_reviews = [review for review in payload if isinstance(review, dict)] + collected_reviews.extend(page_reviews) + if len(payload) < 100: + return {"ok": True, "reviews": collected_reviews} + page += 1 + + def get_pull_request_reviews_result(bot, issue_number: int, reviews: list[dict] | None = None) -> dict[str, object]: if reviews is not None: return {"ok": True, "reviews": reviews} @@ -70,21 +95,7 @@ def get_pull_request_reviews_result(bot, issue_number: int, reviews: list[dict] retry_policy="idempotent_read", ) except SystemExit: - fallback_loader = getattr(bot, "get_pull_request_reviews", None) - if callable(fallback_loader) and not _is_canonical_callable(fallback_loader): - fallback_reviews = fallback_loader(issue_number) - if not isinstance(fallback_reviews, list): - return _projection_failure("reviews_unavailable") - return {"ok": True, "reviews": fallback_reviews} - payload = bot.github_api("GET", f"pulls/{issue_number}/reviews?per_page=100&page={page}") - if not isinstance(payload, list): - return _projection_failure("reviews_unavailable") - page_reviews = [review for review in payload if isinstance(review, dict)] - collected_reviews.extend(page_reviews) - if len(payload) < 100: - return {"ok": True, "reviews": collected_reviews} - page += 1 - continue + return _fallback_pull_request_reviews_result(bot, issue_number) if not response.ok: return _projection_failure("reviews_unavailable", response.failure_kind) payload = response.payload @@ -384,6 +395,38 @@ def compute_pr_approval_state_result( return result +def resolve_pr_approval_state( + bot, + issue_number: int, + review_data: dict, + *, + pull_request: dict | None = None, + reviews: list[dict] | None = None, +) -> tuple[dict | None, dict | None, str | None]: + if not _is_canonical_callable(bot.reviews_module.rebuild_pr_approval_state): + completion, write_approval = bot.reviews_module.rebuild_pr_approval_state( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if completion is None or write_approval is None: + return None, None, "live_review_state_unknown" + return completion, write_approval, None + + approval_result = compute_pr_approval_state_result( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if not approval_result.get("ok"): + return None, None, str(approval_result.get("reason")) + return approval_result["completion"], approval_result["write_approval"], None + + def apply_pr_approval_state( review_data: dict, *, @@ -1012,28 +1055,15 @@ def compute_reviewer_response_state( "contributor_handoff": contributor_handoff, } - if not _is_canonical_callable(bot.reviews_module.rebuild_pr_approval_state): - completion, write_approval = bot.reviews_module.rebuild_pr_approval_state( - bot, - issue_number, - review_data, - pull_request=pull_request, - reviews=reviews, - ) - if completion is None or write_approval is None: - return {"state": "projection_failed", "reason": "live_review_state_unknown"} - else: - approval_result = compute_pr_approval_state_result( - bot, - issue_number, - review_data, - pull_request=pull_request, - reviews=reviews, - ) - if not approval_result.get("ok"): - return {"state": "projection_failed", "reason": str(approval_result.get("reason"))} - completion = approval_result["completion"] - write_approval = approval_result["write_approval"] + completion, write_approval, approval_failure = resolve_pr_approval_state( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if completion is None or write_approval is None: + return {"state": "projection_failed", "reason": approval_failure or "live_review_state_unknown"} if not completion.get("completed"): return { "state": "awaiting_contributor_response", From 20a05f51ee3d0d672352f4795c693f83d4b7dc0e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:29:14 +0900 Subject: [PATCH 034/209] refactor(reviewer-bot): split reviews normalization helpers --- scripts/reviewer_bot_lib/reviews.py | 27 +++++-------- .../reviewer_bot_lib/reviews_projection.py | 28 ++++++++++++++ .../reviewer_bot/test_reviews_projection.py | 38 +++++++++++++++++++ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index d36704466..e8d7a4ec7 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -13,9 +13,11 @@ STATUS_LABELS, ) from .reviews_projection import ( + collect_permission_statuses, compute_pr_approval_state_from_reviews, desired_labels_from_response_state, filter_current_head_reviews_for_cycle, + normalize_reviews_with_parsed_timestamps, ) @@ -363,28 +365,19 @@ def compute_pr_approval_state_result( return reviews_result reviews = reviews_result["reviews"] - normalized_reviews = [] - for review in reviews: - if not isinstance(review, dict): - normalized_reviews.append(review) - continue - normalized = dict(review) - normalized["submitted_at"] = parse_github_timestamp(review.get("submitted_at")) - normalized_reviews.append(normalized) - + normalized_reviews = normalize_reviews_with_parsed_timestamps( + reviews, + parse_timestamp=parse_github_timestamp, + ) survivors = filter_current_head_reviews_for_cycle( normalized_reviews, boundary=boundary, current_head=current_head, ) - permission_cache: dict[str, str] = {} - for review in survivors.values(): - author = review.get("user", {}).get("login") - if not isinstance(author, str) or not author.strip(): - continue - cache_key = author.lower() - if cache_key not in permission_cache: - permission_cache[cache_key] = _permission_status(bot, author, "push") + permission_cache = collect_permission_statuses( + survivors, + permission_status=lambda author: _permission_status(bot, author, "push"), + ) result = compute_pr_approval_state_from_reviews( survivors, current_head=current_head, diff --git a/scripts/reviewer_bot_lib/reviews_projection.py b/scripts/reviewer_bot_lib/reviews_projection.py index cdf10af7d..aae4279f1 100644 --- a/scripts/reviewer_bot_lib/reviews_projection.py +++ b/scripts/reviewer_bot_lib/reviews_projection.py @@ -49,6 +49,34 @@ def filter_current_head_reviews_for_cycle( return survivors +def normalize_reviews_with_parsed_timestamps( + reviews: list[dict], + *, + parse_timestamp, +) -> list[dict]: + normalized_reviews = [] + for review in reviews: + if not isinstance(review, dict): + normalized_reviews.append(review) + continue + normalized = dict(review) + normalized["submitted_at"] = parse_timestamp(review.get("submitted_at")) + normalized_reviews.append(normalized) + return normalized_reviews + + +def collect_permission_statuses(survivors: dict[str, dict], *, permission_status) -> dict[str, str]: + statuses: dict[str, str] = {} + for review in survivors.values(): + author = review.get("user", {}).get("login") + if not isinstance(author, str) or not author.strip(): + continue + cache_key = author.lower() + if cache_key not in statuses: + statuses[cache_key] = permission_status(author) + return statuses + + def compute_pr_approval_state_from_reviews( survivors: dict[str, dict], *, diff --git a/tests/unit/reviewer_bot/test_reviews_projection.py b/tests/unit/reviewer_bot/test_reviews_projection.py index 845b5c427..c2f79812c 100644 --- a/tests/unit/reviewer_bot/test_reviews_projection.py +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -115,3 +115,41 @@ def test_compute_pr_approval_state_from_reviews_is_pure(): assert result["ok"] is True assert result["completion"]["completed"] is True assert before["survivors"]["alice"]["id"] == 10 + + +def test_normalize_reviews_with_parsed_timestamps_is_pure(): + reviews = [ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ] + before = json.loads(json.dumps(reviews)) + + normalized = reviews_projection.normalize_reviews_with_parsed_timestamps( + reviews, + parse_timestamp=reviewer_bot.parse_github_timestamp, + ) + + assert normalized[0]["submitted_at"] == reviewer_bot.parse_github_timestamp("2026-03-17T10:01:00Z") + assert reviews == before + + +def test_collect_permission_statuses_deduplicates_authors(): + survivors = { + "alice": {"user": {"login": "alice"}}, + "alice-2": {"user": {"login": "alice"}}, + "bob": {"user": {"login": "bob"}}, + } + observed = [] + + statuses = reviews_projection.collect_permission_statuses( + survivors, + permission_status=lambda author: observed.append(author) or "granted", + ) + + assert statuses == {"alice": "granted", "bob": "granted"} + assert observed == ["alice", "bob"] From b7be34d9c3752794aa8e7c5784692cbc93e29c1a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:34:00 +0900 Subject: [PATCH 035/209] refactor(reviewer-bot): add typed deferred reconcile payloads --- scripts/reviewer_bot_lib/reconcile.py | 181 ++++++++++++++---- .../unit/reviewer_bot/test_reconcile_unit.py | 122 ++++++++++++ 2 files changed, 268 insertions(+), 35 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_reconcile_unit.py diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 2c43725b6..3a73904ef 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -36,6 +36,64 @@ class LiveCommentReplayValidationResult: failed_closed: bool +@dataclass(frozen=True) +class DeferredArtifactIdentity: + schema_version: int + source_workflow_name: str + source_workflow_file: str + source_run_id: int + source_run_attempt: int + source_event_name: str + source_event_action: str + source_event_key: str + + +@dataclass(frozen=True) +class DeferredReviewPayload: + identity: DeferredArtifactIdentity + pr_number: int + review_id: int + source_submitted_at: str | None + source_review_state: str | None + source_commit_id: str | None + actor_login: str | None + raw_payload: dict + + +@dataclass(frozen=True) +class DeferredCommentPayload: + identity: DeferredArtifactIdentity + pr_number: int + comment_id: int + comment_class: str + has_non_command_text: bool + source_body_digest: str + source_created_at: str + actor_login: str | None + raw_payload: dict + + +@dataclass(frozen=True) +class ObserverNoopPayload: + identity: DeferredArtifactIdentity + pr_number: int + reason: str + raw_payload: dict + + +def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: + return DeferredArtifactIdentity( + schema_version=int(payload["schema_version"]), + source_workflow_name=str(payload["source_workflow_name"]), + source_workflow_file=str(payload["source_workflow_file"]), + source_run_id=int(payload["source_run_id"]), + source_run_attempt=int(payload["source_run_attempt"]), + source_event_name=str(payload["source_event_name"]), + source_event_action=str(payload["source_event_action"]), + source_event_key=str(payload["source_event_key"]), + ) + + def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: try: response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") @@ -276,6 +334,68 @@ def _load_deferred_context() -> dict: return payload +def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | DeferredCommentPayload | ObserverNoopPayload: + if not isinstance(payload, dict): + raise RuntimeError("Deferred context payload must be a JSON object") + + if payload.get("kind") == "observer_noop": + _validate_observer_noop_payload(payload) + return ObserverNoopPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + reason=str(payload["reason"]), + raw_payload=payload, + ) + + event_name = payload.get("source_event_name") + event_action = payload.get("source_event_action") + if event_name == "issue_comment" and event_action == "created": + _validate_deferred_comment_artifact(payload) + return DeferredCommentPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + comment_id=int(payload["comment_id"]), + comment_class=str(payload["comment_class"]), + has_non_command_text=bool(payload["has_non_command_text"]), + source_body_digest=str(payload["source_body_digest"]), + source_created_at=str(payload["source_created_at"]), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + if event_name == "pull_request_review_comment" and event_action == "created": + _validate_deferred_review_comment_artifact(payload) + return DeferredCommentPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + comment_id=int(payload["comment_id"]), + comment_class=str(payload["comment_class"]), + has_non_command_text=bool(payload["has_non_command_text"]), + source_body_digest=str(payload["source_body_digest"]), + source_created_at=str(payload["source_created_at"]), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + if event_name == "pull_request_review" and event_action in {"submitted", "dismissed"}: + _validate_deferred_review_artifact(payload) + return DeferredReviewPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + review_id=int(payload["review_id"]), + source_submitted_at=( + str(payload["source_submitted_at"]) if payload.get("source_submitted_at") is not None else None + ), + source_review_state=( + str(payload["source_review_state"]) if payload.get("source_review_state") is not None else None + ), + source_commit_id=( + str(payload["source_commit_id"]) if payload.get("source_commit_id") is not None else None + ), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + raise RuntimeError("Unsupported deferred workflow_run payload") + + def _set_env_if_present(name: str, value) -> None: if value is None: return @@ -612,59 +732,53 @@ def handle_workflow_run_event(bot, state: dict) -> bool: print("V18 workflow_run reconcile safe-noop before epoch flip") return False payload = _load_deferred_context() - pr_number = int(payload.get("pr_number", 0) or 0) + parsed_payload = parse_deferred_context_payload(payload) + pr_number = parsed_payload.pr_number if pr_number <= 0: raise RuntimeError("Deferred context is missing a valid PR number") bot.collect_touched_item(pr_number) review_data = bot.ensure_review_entry(state, pr_number, create=True) if review_data is None: raise RuntimeError(f"No review entry available for PR #{pr_number}") - event_name = payload.get("source_event_name") - event_action = payload.get("source_event_action") - source_event_key = str(payload.get("source_event_key", "")) + event_name = parsed_payload.identity.source_event_name + event_action = parsed_payload.identity.source_event_action + source_event_key = parsed_payload.identity.source_event_key try: - if payload.get("kind") == "observer_noop": - _validate_observer_noop_payload(payload) - _validate_workflow_run_artifact_identity(payload) + if isinstance(parsed_payload, ObserverNoopPayload): + _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) print( "Observer workflow produced explicit no-op payload for " - f"{source_event_key}: {payload.get('reason')}" + f"{source_event_key}: {parsed_payload.reason}" ) return False - if event_name == "issue_comment": - _validate_deferred_comment_artifact(payload) - _validate_workflow_run_artifact_identity(payload) + if event_name == "issue_comment" and isinstance(parsed_payload, DeferredCommentPayload): + _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) return _reconcile_deferred_comment( bot, state, pr_number, review_data, - payload, + parsed_payload.raw_payload, expected_event_name="issue_comment", - live_comment_endpoint=f"issues/comments/{payload['comment_id']}", + live_comment_endpoint=f"issues/comments/{parsed_payload.comment_id}", ) - if event_name == "pull_request_review_comment" and event_action == "created": - _validate_deferred_review_comment_artifact(payload) - _validate_workflow_run_artifact_identity(payload) + if event_name == "pull_request_review_comment" and event_action == "created" and isinstance(parsed_payload, DeferredCommentPayload): + _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) return _reconcile_deferred_comment( bot, state, pr_number, review_data, - payload, + parsed_payload.raw_payload, expected_event_name="pull_request_review_comment", - live_comment_endpoint=f"pulls/comments/{payload['comment_id']}", + live_comment_endpoint=f"pulls/comments/{parsed_payload.comment_id}", ) - if event_name == "pull_request_review" and event_action == "submitted": - _validate_deferred_review_artifact(payload) - _validate_workflow_run_artifact_identity(payload) - review_id_value = payload.get("review_id") - if not isinstance(review_id_value, int): - raise RuntimeError("Deferred review artifact review_id must be an integer") - review_id = review_id_value + if event_name == "pull_request_review" and event_action == "submitted" and isinstance(parsed_payload, DeferredReviewPayload): + _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + review_id = parsed_payload.review_id if source_event_key != f"pull_request_review:{review_id}": raise RuntimeError("Deferred review-submitted artifact source_event_key mismatch") try: @@ -676,15 +790,15 @@ def handle_workflow_run_event(bot, state: dict) -> bool: raise _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number}") live_commit_id = None - live_submitted_at = payload.get("source_submitted_at") - live_state = payload.get("source_review_state") + live_submitted_at = parsed_payload.source_submitted_at + live_state = parsed_payload.source_review_state if isinstance(live_review, dict): live_commit_id = live_review.get("commit_id") live_submitted_at = live_review.get("submitted_at") or live_submitted_at live_state = live_review.get("state") or live_state else: - live_commit_id = payload.get("source_commit_id") - actor = str(payload.get("actor_login", "")) + live_commit_id = parsed_payload.source_commit_id + actor = parsed_payload.actor_login or "" state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): bot.reviews_module.accept_channel_event( @@ -704,12 +818,9 @@ def handle_workflow_run_event(bot, state: dict) -> bool: gap_cleared_changed = _clear_source_event_key(review_data, source_event_key) return state_changed or reconciled_changed or gap_cleared_changed - if event_name == "pull_request_review" and event_action == "dismissed": - _validate_deferred_review_artifact(payload) - _validate_workflow_run_artifact_identity(payload) - review_id_value = payload.get("review_id") - if not isinstance(review_id_value, int): - raise RuntimeError("Deferred review artifact review_id must be an integer") + if event_name == "pull_request_review" and event_action == "dismissed" and isinstance(parsed_payload, DeferredReviewPayload): + _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + review_id_value = parsed_payload.review_id if source_event_key != f"pull_request_review_dismissed:{review_id_value}": raise RuntimeError("Deferred review-dismissed artifact source_event_key mismatch") bot.reviews_module.accept_channel_event( diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py new file mode 100644 index 000000000..1cebdc204 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -0,0 +1,122 @@ +import pytest + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_parse_deferred_context_payload_returns_typed_review_payload(): + payload = { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + + parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + + assert isinstance(parsed, reviewer_bot.reconcile_module.DeferredReviewPayload) + assert parsed.identity.source_event_name == "pull_request_review" + assert parsed.review_id == 11 + assert parsed.pr_number == 42 + + +def test_parse_deferred_context_payload_returns_typed_comment_payload(): + payload = { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 610, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": "abc123", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + + parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + + assert isinstance(parsed, reviewer_bot.reconcile_module.DeferredCommentPayload) + assert parsed.identity.source_event_key == "issue_comment:210" + assert parsed.comment_id == 210 + + +def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): + payload = { + "schema_version": 1, + "kind": "observer_noop", + "reason": "not a command", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 610, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + } + + parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + + assert isinstance(parsed, reviewer_bot.reconcile_module.ObserverNoopPayload) + assert parsed.reason == "not a command" + assert parsed.pr_number == 42 + + +def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, **kwargs: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + + message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( + reviewer_bot, + state, + 42, + require_pull_request_context=True, + ) + + assert success is True + assert changed is False + assert "no reconciliation transitions applied" in message + + +def test_parse_deferred_context_payload_rejects_unsupported_payload(): + with pytest.raises(RuntimeError, match="Unsupported deferred workflow_run payload"): + reviewer_bot.reconcile_module.parse_deferred_context_payload({"schema_version": 2}) From 464ed8ff6bdb82e3994d52871cac101859eaa469 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:37:45 +0900 Subject: [PATCH 036/209] refactor(reviewer-bot): add typed comment replay context --- scripts/reviewer_bot_lib/reconcile.py | 94 ++++++++++++++----- .../unit/reviewer_bot/test_reconcile_unit.py | 64 +++++++++++++ 2 files changed, 132 insertions(+), 26 deletions(-) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 3a73904ef..c1758c333 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -81,6 +81,37 @@ class ObserverNoopPayload: raw_payload: dict +@dataclass(frozen=True) +class DeferredCommentReplayContext: + payload: DeferredCommentPayload + expected_event_name: str + live_comment_endpoint: str + + @property + def source_event_key(self) -> str: + return self.payload.identity.source_event_key + + @property + def comment_id(self) -> int: + return self.payload.comment_id + + @property + def pr_number(self) -> int: + return self.payload.pr_number + + @property + def actor_login(self) -> str: + return self.payload.actor_login or "" + + @property + def source_created_at(self) -> str: + return self.payload.source_created_at + + @property + def source_freshness_eligible(self) -> bool: + return self.payload.comment_class in {"plain_text", "command_plus_text"} and self.payload.has_non_command_text + + def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: return DeferredArtifactIdentity( schema_version=int(payload["schema_version"]), @@ -94,6 +125,21 @@ def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: ) +def build_deferred_comment_replay_context( + payload: DeferredCommentPayload, + *, + expected_event_name: str, + live_comment_endpoint: str, +) -> DeferredCommentReplayContext: + if payload.identity.source_event_key != f"{expected_event_name}:{payload.comment_id}": + raise RuntimeError("Deferred comment artifact source_event_key mismatch") + return DeferredCommentReplayContext( + payload=payload, + expected_event_name=expected_event_name, + live_comment_endpoint=live_comment_endpoint, + ) + + def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: try: response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") @@ -558,27 +604,19 @@ def _artifact_expected_payload_name(payload: dict) -> str: def _reconcile_deferred_comment( bot, state: dict, - pr_number: int, review_data: dict, - payload: dict, - *, - expected_event_name: str, - live_comment_endpoint: str, + context: DeferredCommentReplayContext, ) -> bool: - comment_id_value = payload.get("comment_id") - if not isinstance(comment_id_value, int): - raise RuntimeError("Deferred comment artifact comment_id must be an integer") - comment_id = comment_id_value - if str(payload.get("source_event_key", "")) != f"{expected_event_name}:{comment_id}": - raise RuntimeError("Deferred comment artifact source_event_key mismatch") - _set_env_if_present("COMMENT_SOURCE_EVENT_KEY", payload.get("source_event_key")) + payload = context.payload.raw_payload + comment_id = context.comment_id + pr_number = context.pr_number + _set_env_if_present("COMMENT_SOURCE_EVENT_KEY", context.source_event_key) _hydrate_reconcile_pr_context(bot, pr_number) - comment_author = str(payload.get("actor_login", "")) - comment_created_at = str(payload.get("source_created_at")) - classified = payload.get("comment_class") - source_freshness_eligible = classified in {"plain_text", "command_plus_text"} and bool(payload.get("has_non_command_text")) + comment_author = context.actor_login + comment_created_at = context.source_created_at + source_freshness_eligible = context.source_freshness_eligible try: - live_comment = _read_reconcile_object(bot, live_comment_endpoint, label=f"deferred comment {comment_id}") + live_comment = _read_reconcile_object(bot, context.live_comment_endpoint, label=f"deferred comment {comment_id}") except ReconcileReadError as exc: changed = False if source_freshness_eligible: @@ -624,7 +662,7 @@ def _reconcile_deferred_comment( if validation_result.live_classified is None: return changed or validation_result.changed live_classified = validation_result.live_classified - if classified in {"command_only", "command_plus_text"}: + if context.payload.comment_class in {"command_only", "command_plus_text"}: changed = _handle_command(bot, state, pr_number, comment_author, live_classified) or changed reconciled_changed = _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) gap_cleared_changed = _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) @@ -754,26 +792,30 @@ def handle_workflow_run_event(bot, state: dict) -> bool: if event_name == "issue_comment" and isinstance(parsed_payload, DeferredCommentPayload): _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + context = build_deferred_comment_replay_context( + parsed_payload, + expected_event_name="issue_comment", + live_comment_endpoint=f"issues/comments/{parsed_payload.comment_id}", + ) return _reconcile_deferred_comment( bot, state, - pr_number, review_data, - parsed_payload.raw_payload, - expected_event_name="issue_comment", - live_comment_endpoint=f"issues/comments/{parsed_payload.comment_id}", + context, ) if event_name == "pull_request_review_comment" and event_action == "created" and isinstance(parsed_payload, DeferredCommentPayload): _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + context = build_deferred_comment_replay_context( + parsed_payload, + expected_event_name="pull_request_review_comment", + live_comment_endpoint=f"pulls/comments/{parsed_payload.comment_id}", + ) return _reconcile_deferred_comment( bot, state, - pr_number, review_data, - parsed_payload.raw_payload, - expected_event_name="pull_request_review_comment", - live_comment_endpoint=f"pulls/comments/{parsed_payload.comment_id}", + context, ) if event_name == "pull_request_review" and event_action == "submitted" and isinstance(parsed_payload, DeferredReviewPayload): diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 1cebdc204..49c854ca4 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -56,6 +56,70 @@ def test_parse_deferred_context_payload_returns_typed_comment_payload(): assert parsed.comment_id == 210 +def test_build_deferred_comment_replay_context_returns_typed_context(): + payload = reviewer_bot.reconcile_module.DeferredCommentPayload( + identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + schema_version=2, + source_workflow_name="Reviewer Bot PR Comment Observer", + source_workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", + source_run_id=610, + source_run_attempt=1, + source_event_name="issue_comment", + source_event_action="created", + source_event_key="issue_comment:210", + ), + pr_number=42, + comment_id=210, + comment_class="command_plus_text", + has_non_command_text=True, + source_body_digest="abc123", + source_created_at="2026-03-17T10:00:00Z", + actor_login="bob", + raw_payload={"source_event_key": "issue_comment:210"}, + ) + + context = reviewer_bot.reconcile_module.build_deferred_comment_replay_context( + payload, + expected_event_name="issue_comment", + live_comment_endpoint="issues/comments/210", + ) + + assert isinstance(context, reviewer_bot.reconcile_module.DeferredCommentReplayContext) + assert context.comment_id == 210 + assert context.pr_number == 42 + assert context.source_freshness_eligible is True + + +def test_build_deferred_comment_replay_context_rejects_mismatched_source_event_key(): + payload = reviewer_bot.reconcile_module.DeferredCommentPayload( + identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + schema_version=2, + source_workflow_name="Reviewer Bot PR Comment Observer", + source_workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", + source_run_id=610, + source_run_attempt=1, + source_event_name="issue_comment", + source_event_action="created", + source_event_key="issue_comment:999", + ), + pr_number=42, + comment_id=210, + comment_class="command_only", + has_non_command_text=False, + source_body_digest="abc123", + source_created_at="2026-03-17T10:00:00Z", + actor_login="bob", + raw_payload={"source_event_key": "issue_comment:999"}, + ) + + with pytest.raises(RuntimeError, match="source_event_key mismatch"): + reviewer_bot.reconcile_module.build_deferred_comment_replay_context( + payload, + expected_event_name="issue_comment", + live_comment_endpoint="issues/comments/210", + ) + + def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): payload = { "schema_version": 1, From d93c222e3e8001be5f969827571cd9929a6377ef Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:50:16 +0900 Subject: [PATCH 037/209] refactor(reviewer-bot): add typed review replay context --- scripts/reviewer_bot_lib/reconcile.py | 55 ++++++++++++++--- .../unit/reviewer_bot/test_reconcile_unit.py | 60 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index c1758c333..928c7c24c 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -112,6 +112,27 @@ def source_freshness_eligible(self) -> bool: return self.payload.comment_class in {"plain_text", "command_plus_text"} and self.payload.has_non_command_text +@dataclass(frozen=True) +class DeferredReviewReplayContext: + payload: DeferredReviewPayload + + @property + def source_event_key(self) -> str: + return self.payload.identity.source_event_key + + @property + def review_id(self) -> int: + return self.payload.review_id + + @property + def pr_number(self) -> int: + return self.payload.pr_number + + @property + def actor_login(self) -> str: + return self.payload.actor_login or "" + + def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: return DeferredArtifactIdentity( schema_version=int(payload["schema_version"]), @@ -140,6 +161,23 @@ def build_deferred_comment_replay_context( ) +def build_deferred_review_replay_context( + payload: DeferredReviewPayload, + *, + expected_event_action: str, +) -> DeferredReviewReplayContext: + expected_prefix = ( + "pull_request_review:" + if expected_event_action == "submitted" + else "pull_request_review_dismissed:" + ) + if payload.identity.source_event_action != expected_event_action: + raise RuntimeError("Deferred review artifact action mismatch") + if payload.identity.source_event_key != f"{expected_prefix}{payload.review_id}": + raise RuntimeError(f"Deferred review-{expected_event_action} artifact source_event_key mismatch") + return DeferredReviewReplayContext(payload=payload) + + def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: try: response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") @@ -820,9 +858,11 @@ def handle_workflow_run_event(bot, state: dict) -> bool: if event_name == "pull_request_review" and event_action == "submitted" and isinstance(parsed_payload, DeferredReviewPayload): _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) - review_id = parsed_payload.review_id - if source_event_key != f"pull_request_review:{review_id}": - raise RuntimeError("Deferred review-submitted artifact source_event_key mismatch") + context = build_deferred_review_replay_context( + parsed_payload, + expected_event_action="submitted", + ) + review_id = context.review_id try: live_review = _read_reconcile_object(bot, f"pulls/{pr_number}/reviews/{review_id}", label=f"live review #{review_id}") except ReconcileReadError as exc: @@ -840,7 +880,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: live_state = live_review.get("state") or live_state else: live_commit_id = parsed_payload.source_commit_id - actor = parsed_payload.actor_login or "" + actor = context.actor_login state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): bot.reviews_module.accept_channel_event( @@ -862,9 +902,10 @@ def handle_workflow_run_event(bot, state: dict) -> bool: if event_name == "pull_request_review" and event_action == "dismissed" and isinstance(parsed_payload, DeferredReviewPayload): _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) - review_id_value = parsed_payload.review_id - if source_event_key != f"pull_request_review_dismissed:{review_id_value}": - raise RuntimeError("Deferred review-dismissed artifact source_event_key mismatch") + context = build_deferred_review_replay_context( + parsed_payload, + expected_event_action="dismissed", + ) bot.reviews_module.accept_channel_event( review_data, "review_dismissal", diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 49c854ca4..d2d57d567 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -120,6 +120,66 @@ def test_build_deferred_comment_replay_context_rejects_mismatched_source_event_k ) +def test_build_deferred_review_replay_context_returns_typed_context(): + payload = reviewer_bot.reconcile_module.DeferredReviewPayload( + identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + schema_version=2, + source_workflow_name="Reviewer Bot PR Review Submitted Observer", + source_workflow_file=".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + source_run_id=500, + source_run_attempt=2, + source_event_name="pull_request_review", + source_event_action="submitted", + source_event_key="pull_request_review:11", + ), + pr_number=42, + review_id=11, + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + raw_payload={"source_event_key": "pull_request_review:11"}, + ) + + context = reviewer_bot.reconcile_module.build_deferred_review_replay_context( + payload, + expected_event_action="submitted", + ) + + assert isinstance(context, reviewer_bot.reconcile_module.DeferredReviewReplayContext) + assert context.review_id == 11 + assert context.pr_number == 42 + assert context.actor_login == "alice" + + +def test_build_deferred_review_replay_context_rejects_mismatched_source_event_key(): + payload = reviewer_bot.reconcile_module.DeferredReviewPayload( + identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + schema_version=2, + source_workflow_name="Reviewer Bot PR Review Submitted Observer", + source_workflow_file=".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + source_run_id=500, + source_run_attempt=2, + source_event_name="pull_request_review", + source_event_action="submitted", + source_event_key="pull_request_review:99", + ), + pr_number=42, + review_id=11, + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + raw_payload={"source_event_key": "pull_request_review:99"}, + ) + + with pytest.raises(RuntimeError, match="source_event_key mismatch"): + reviewer_bot.reconcile_module.build_deferred_review_replay_context( + payload, + expected_event_action="submitted", + ) + + def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): payload = { "schema_version": 1, From 0d920570356972c4428b263be24c0d86aff29e8b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:54:15 +0900 Subject: [PATCH 038/209] test(reviewer-bot): extract reconcile workflow run tests --- .../test_reconcile_workflow_run.py | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_reconcile_workflow_run.py diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py new file mode 100644 index 000000000..f88967b19 --- /dev/null +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -0,0 +1,240 @@ +import json + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing +from tests.fixtures.reviewer_bot import make_state + + +def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations( + tmp_path, monkeypatch +): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "bob" + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/11": + return { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "pull_request_review:11" in review["reconciled_source_events"] + assert "pull_request_review:11" not in review["deferred_gaps"] + + +def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:12", + "pr_number": 42, + "review_id": 12, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "pulls/42/reviews/12": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = review["deferred_gaps"]["pull_request_review:12"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" + + +def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /queue" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 610, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:210", + "pr_number": 42, + "comment_id": 210, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/210": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert "issue_comment:210" in review["reconciled_source_events"] + assert "issue_comment:210" not in review["deferred_gaps"] + + +def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 501, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:99", + "pr_number": 42, + "comment_id": 99, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" From bdc33b9ccd38258662b07a5506fe31f72651b34b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 09:59:22 +0900 Subject: [PATCH 039/209] test(reviewer-bot): expand reconcile workflow run tests --- .../test_reconcile_workflow_run.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index f88967b19..fa0a64e5a 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -238,3 +238,214 @@ def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tm assert reviewer_bot.handle_workflow_run_event(state) is True assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" + + +def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + payload_path = tmp_path / "deferred.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "APPROVED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"head": {"sha": "head-2"}}, + "pulls/42/reviews/11": { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "APPROVED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "APPROVED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False + + +def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review( + tmp_path, monkeypatch +): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_review", + semantic_key="pull_request_review:99", + timestamp="2026-03-17T11:00:00Z", + actor="alice", + reviewed_head_sha="head-0", + source_precedence=1, + ) + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:99", + "pr_number": 42, + "review_id": 99, + "source_submitted_at": "2026-03-17T11:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-0", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42/reviews/99": + return { + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 10, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + { + "id": 99, + "submitted_at": "2026-03-17T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-0", + "user": {"login": "alice"}, + }, + ], + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + accepted = review["reviewer_review"]["accepted"] + assert accepted["semantic_key"] == "pull_request_review:10" + assert accepted["reviewed_head_sha"] == "head-1" + + +def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "author reply in review thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 701, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:301", + "pr_number": 42, + "comment_id": 301, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + "actor_id": 5, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/301": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] + assert accepted is not None + assert accepted["semantic_key"] == "pull_request_review_comment:301" From 3a67f3b819cb6cb2e6594f0667ee8f9d23517c82 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 10:01:10 +0900 Subject: [PATCH 040/209] test(reviewer-bot): expand reconcile replay coverage --- .../test_reconcile_workflow_run.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index fa0a64e5a..fe25f4d35 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -449,3 +449,183 @@ def fake_github_api(method, endpoint, data=None): accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted is not None assert accepted["semantic_key"] == "pull_request_review_comment:301" + + +def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + live_body = "reviewer reply in thread" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 702, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:302", + "pr_number": 42, + "comment_id": 302, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T11:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/comments/302": + return { + "body": live_body, + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" + + +def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 703, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:303", + "pr_number": 42, + "comment_id": 303, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" + assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" + + +def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /claim" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 603, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:201", + "pr_number": 42, + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "issues/comments/201": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + command_calls = [] + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_handle_command", + lambda *args, **kwargs: command_calls.append("called") or True, + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert command_calls == [] + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" + assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] From c6f57e4e2bdb8dbbd87175836bd88c23a170331a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 10:03:08 +0900 Subject: [PATCH 041/209] test(reviewer-bot): expand reconcile comment replay tests --- .../test_reconcile_workflow_run.py | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index fe25f4d35..16ece636d 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -1,4 +1,5 @@ import json +import os from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing @@ -629,3 +630,222 @@ def fake_github_api(method, endpoint, data=None): assert command_calls == [] assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] + + +def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 601, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:199", + "pr_number": 42, + "comment_id": 199, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "601") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} + if endpoint == "issues/comments/199": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None + assert os.environ["IS_PULL_REQUEST"] == "true" + assert os.environ["ISSUE_AUTHOR"] == "dana" + assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] + + +def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): + state = make_state() + state["queue"] = [{"github": "bob", "name": "Bob"}] + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-command.json" + live_body = "@guidelines-bot /claim" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 602, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:200", + "pr_number": 42, + "comment_id": 200, + "comment_class": "command_only", + "has_non_command_text": False, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "602") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + assignment_calls = [] + removed_reviewers = [] + posted_comments = [] + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return { + "user": {"login": "dana"}, + "labels": [{"name": "coding guideline"}], + "requested_reviewers": [{"login": "alice"}], + } + if endpoint == "issues/comments/200": + return { + "body": live_body, + "user": {"login": "bob", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + def fake_request(issue_number, username): + assignment_calls.append( + { + "issue_number": issue_number, + "username": username, + "is_pull_request": os.environ.get("IS_PULL_REQUEST"), + "issue_author": os.environ.get("ISSUE_AUTHOR"), + } + ) + return reviewer_bot.AssignmentAttempt(success=True, status_code=201) + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", fake_request) + monkeypatch.setattr( + reviewer_bot, + "unassign_reviewer", + lambda issue_number, username: removed_reviewers.append((issue_number, username)) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "post_comment", + lambda issue_number, body: posted_comments.append((issue_number, body)) or True, + ) + monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert assignment_calls == [ + { + "issue_number": 42, + "username": "bob", + "is_pull_request": "true", + "issue_author": "dana", + } + ] + assert removed_reviewers == [(42, "alice")] + assert state["active_reviews"]["42"]["current_reviewer"] == "bob" + assert posted_comments + + +def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment-unavailable.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 603, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:205", + "pr_number": 42, + "comment_id": 205, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): + if endpoint == "pulls/42": + return reviewer_bot.GitHubApiResult( + status_code=200, + payload={"user": {"login": "dana"}, "labels": []}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + if endpoint == "issues/comments/205": + return reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) + raise AssertionError(endpoint) + + monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + + assert reviewer_bot.handle_workflow_run_event(state) is True + gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] + assert gap["reason"] == "reconcile_failed_closed" + assert gap["failure_kind"] == "server_error" From 644a9d2a67a7711a44bad49fdb16800159efef49 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 10:08:40 +0900 Subject: [PATCH 042/209] test(reviewer-bot): expand reconcile fail closed coverage --- .../test_reconcile_workflow_run.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 16ece636d..3b5caf6d0 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -849,3 +849,70 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] assert gap["reason"] == "reconcile_failed_closed" assert gap["failure_kind"] == "server_error" + + +def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-comment.json" + live_body = "reviewer-bot validation: contributor plain text comment" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 604, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:202", + "pr_number": 42, + "comment_id": 202, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": comment_routing._digest_body(live_body), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "dana", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "604") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"user": {"login": "dana"}, "labels": []} + if endpoint == "issues/comments/202": + return { + "body": live_body, + "user": {"login": "dana", "type": "User"}, + "author_association": "CONTRIBUTOR", + "performed_via_github_app": None, + } + raise AssertionError(f"Unexpected endpoint: {endpoint}") + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_plus_text", + "has_non_command_text": True, + "command_count": 1, + "command": "claim", + "args": [], + "normalized_body": body, + }, + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" + assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" + assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] From a3119763634dda5ba90047f20912a5e01da27ee1 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 21:12:36 +0900 Subject: [PATCH 043/209] test(reviewer-bot): extract reconcile privileged command tests --- .../test_reconcile_privileged_command.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/integration/reviewer_bot/test_reconcile_privileged_command.py diff --git a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py new file mode 100644 index 000000000..80fee31ff --- /dev/null +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -0,0 +1,103 @@ +import json +import os + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", lambda issue_number, actor: ("ok", True)) + + assert reviewer_bot.handle_manual_dispatch(state) is True + assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + + +def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.delenv("ISSUE_LABELS", raising=False) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + + observed = {} + + def fake_handle(issue_number, actor): + observed["issue_number"] = issue_number + observed["actor"] = actor + observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) + return ("ok", True) + + monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", fake_handle) + + assert reviewer_bot.handle_manual_dispatch(state) is True + assert observed == { + "issue_number": 42, + "actor": "alice", + "issue_labels": [reviewer_bot.FLS_AUDIT_LABEL], + } + assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + + +def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", + "issue_number": 42, + "actor": "alice", + "status": "pending", + } + monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") + monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]}, + ) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + called = {"handle": 0} + monkeypatch.setattr( + reviewer_bot, + "handle_accept_no_fls_changes_command", + lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), + ) + + assert reviewer_bot.handle_manual_dispatch(state) is True + assert called["handle"] == 0 + pending = review["pending_privileged_commands"]["issue_comment:100"] + assert pending["status"] == "failed_closed" + assert pending["result"] == "live_revalidation_failed" From b53209935b3ee9fc01a8b9f153b79005a0e5f598 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 21:20:33 +0900 Subject: [PATCH 044/209] test(reviewer-bot): move sweeper keyed gap tests --- .github/reviewer-bot-tests/test_reconcile.py | 52 -------------- tests/unit/reviewer_bot/test_sweeper_logic.py | 69 +++++++++++++++++++ 2 files changed, 69 insertions(+), 52 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_sweeper_logic.py diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py index a8dc65009..064a2cbe1 100644 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -1121,58 +1121,6 @@ def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeyp with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): reviewer_bot.resolve_workflow_run_pr_number() -def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-25T10:00:00Z"}], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - {"id": 202, "submitted_at": "2026-03-25T11:00:00Z", "state": "APPROVED"}, - {"id": 303, "submitted_at": "2026-03-25T09:00:00Z", "updated_at": "2026-03-25T12:00:00Z", "state": "DISMISSED"}, - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - gaps = state["active_reviews"]["42"]["deferred_gaps"] - assert "issue_comment:101" in gaps - assert "pull_request_review:202" in gaps - assert "pull_request_review_dismissed:303" in gaps - assert gaps["pull_request_review_dismissed:303"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" - -def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "issues/42/comments?per_page=100&page=1": - return [] - if endpoint == "pulls/42/comments?per_page=100": - return [{"id": 404, "created_at": "2026-03-25T10:30:00Z", "user": {"login": "dana", "type": "User"}}] - if endpoint.startswith("actions/workflows/"): - return {"workflow_runs": []} - return None - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - gaps = state["active_reviews"]["42"]["deferred_gaps"] - assert "pull_request_review_comment:404" in gaps - assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" - def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py new file mode 100644 index 000000000..239a81af8 --- /dev/null +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -0,0 +1,69 @@ +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import sweeper +from tests.fixtures.reviewer_bot import make_state + + +def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-25T10:00:00Z"}], + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + {"id": 202, "submitted_at": "2026-03-25T11:00:00Z", "state": "APPROVED"}, + {"id": 303, "submitted_at": "2026-03-25T09:00:00Z", "updated_at": "2026-03-25T12:00:00Z", "state": "DISMISSED"}, + ], + ) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + gaps = state["active_reviews"]["42"]["deferred_gaps"] + assert "issue_comment:101" in gaps + assert "pull_request_review:202" in gaps + assert "pull_request_review_dismissed:303" in gaps + assert gaps["pull_request_review_dismissed:303"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" + + +def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + def fake_github_api(method, endpoint, data=None): + if endpoint == "pulls/42": + return {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "issues/42/comments?per_page=100&page=1": + return [] + if endpoint == "pulls/42/comments?per_page=100": + return [{"id": 404, "created_at": "2026-03-25T10:30:00Z", "user": {"login": "dana", "type": "User"}}] + if endpoint.startswith("actions/workflows/"): + return {"workflow_runs": []} + return None + + monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + gaps = state["active_reviews"]["42"]["deferred_gaps"] + assert "pull_request_review_comment:404" in gaps + assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" From 956d4013b6ae6558c08dd26de8fdfa884213fc61 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 21:25:40 +0900 Subject: [PATCH 045/209] test(reviewer-bot): move reconcile helper tests --- .github/reviewer-bot-tests/test_reconcile.py | 64 ------------------ .../unit/reviewer_bot/test_reconcile_unit.py | 66 +++++++++++++++++++ 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py index 064a2cbe1..bf0258e85 100644 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -832,48 +832,6 @@ def fake_github_api(method, endpoint, data=None): assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] -def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - payload = { - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_event_key": "issue_comment:201", - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_created_at": "2026-03-17T10:00:00Z", - "pr_number": 42, - "source_run_id": 603, - "source_run_attempt": 1, - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", - } - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_only", - "has_non_command_text": False, - "command_count": 2, - "command": None, - "args": [], - "normalized_body": body, - }, - ) - - result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( - reviewer_bot, - review, - payload, - "@guidelines-bot /claim", - ) - - assert result.live_classified is None - assert result.changed is True - assert result.failed_closed is True - assert review["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" - def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -1099,28 +1057,6 @@ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_ assert pending["status"] == "failed_closed" assert pending["result"] == "live_revalidation_failed" -def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") - monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): - reviewer_bot.resolve_workflow_run_pr_number() - def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index d2d57d567..05f46ba32 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -244,3 +244,69 @@ def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(m def test_parse_deferred_context_payload_rejects_unsupported_payload(): with pytest.raises(RuntimeError, match="Unsupported deferred workflow_run payload"): reviewer_bot.reconcile_module.parse_deferred_context_payload({"schema_version": 2}) + + +def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + payload = { + "comment_id": 201, + "comment_class": "command_only", + "has_non_command_text": False, + "source_event_key": "issue_comment:201", + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_created_at": "2026-03-17T10:00:00Z", + "pr_number": 42, + "source_run_id": 603, + "source_run_attempt": 1, + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", + } + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "classify_comment_payload", + lambda bot, body: { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": body, + }, + ) + + result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( + reviewer_bot, + review, + payload, + "@guidelines-bot /claim", + ) + + assert result.live_classified is None + assert result.changed is True + assert result.failed_closed is True + assert review["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" + + +def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") + monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") + monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ), + ) + + with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): + reviewer_bot.resolve_workflow_run_pr_number() From 24022a3710f4674fb43a08ef54c180d3dc3d6b83 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 21:32:25 +0900 Subject: [PATCH 046/209] test(reviewer-bot): move reconcile artifact tests --- .github/reviewer-bot-tests/test_reconcile.py | 136 ------------------ .../reviewer_bot/test_reconcile_artifacts.py | 53 +++++++ 2 files changed, 53 insertions(+), 136 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_reconcile_artifacts.py diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py index bf0258e85..6102ec481 100644 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -1,7 +1,6 @@ import json import os -import pytest from factories import make_state from scripts import reviewer_bot @@ -580,47 +579,6 @@ def test_deferred_review_comment_missing_live_object_preserves_source_time_fresh assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" -def test_review_comment_artifact_identity_validation(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 704, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:304", - "pr_number": 42, - "comment_id": 304, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-704-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "704") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None) - assert reviewer_bot.handle_workflow_run_event(state) is True - def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -1098,97 +1056,3 @@ def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}]) assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} - - -@pytest.mark.parametrize( - ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), - [ - ( - {"source_event_name": "issue_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Comment Observer", - ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-comment-context-1-attempt-2", - "deferred-comment.json", - ), - ( - {"source_event_name": "pull_request_review", "source_event_action": "submitted", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Submitted Observer", - ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-review-submitted-context-1-attempt-2", - "deferred-review-submitted.json", - ), - ( - {"source_event_name": "pull_request_review", "source_event_action": "dismissed", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Dismissed Observer", - ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-review-dismissed-context-1-attempt-2", - "deferred-review-dismissed.json", - ), - ( - {"source_event_name": "pull_request_review_comment", "source_event_action": "created", "source_run_id": 1, "source_run_attempt": 2}, - "Reviewer Bot PR Review Comment Observer", - ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-review-comment-context-1-attempt-2", - "deferred-review-comment.json", - ), - ], -) -def test_deferred_workflow_identity_helpers_match_expected_contract( - payload, - workflow_name, - workflow_file, - artifact_name, - payload_name, -): - assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( - workflow_name, - workflow_file, - ) - assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name - assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name - -def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - -def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="run_attempt mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - -def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") - payload = { - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 1, - "source_run_attempt": 1, - } - - with pytest.raises(RuntimeError, match="did not conclude successfully"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) diff --git a/tests/unit/reviewer_bot/test_reconcile_artifacts.py b/tests/unit/reviewer_bot/test_reconcile_artifacts.py new file mode 100644 index 000000000..ef13e116c --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_artifacts.py @@ -0,0 +1,53 @@ +import json + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_review_comment_artifact_identity_validation(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + payload_path = tmp_path / "deferred-review-comment.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": 704, + "source_run_attempt": 1, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": "pull_request_review_comment:304", + "pr_number": 42, + "comment_id": 304, + "comment_class": "plain_text", + "has_non_command_text": True, + "source_body_digest": "abc", + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + "actor_id": 6, + "actor_class": "repo_user_principal", + "pull_request_review_id": 10, + "in_reply_to_id": 200, + "source_artifact_name": "reviewer-bot-review-comment-context-704-attempt-1", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "704") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"user": {"login": "dana"}, "labels": []} + if endpoint == "pulls/42" + else None, + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True From 59d10315350a681cf974c9c3fc43c61d69dbb80e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 23:19:17 +0900 Subject: [PATCH 047/209] test(reviewer-bot): move reconcile replay tests --- .github/reviewer-bot-tests/test_reconcile.py | 130 ------------------ .../reviewer_bot/test_reconcile_replay.py | 41 ++++++ 2 files changed, 41 insertions(+), 130 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_reconcile_replay.py diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py index 6102ec481..d01fd05ab 100644 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -7,44 +7,6 @@ from scripts.reviewer_bot_lib import comment_routing, sweeper -def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, **kwargs: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - - message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( - reviewer_bot, - state, - 42, - require_pull_request_context=True, - ) - - assert success is True - assert changed is False - assert "no reconciliation transitions applied" in message - def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -923,98 +885,6 @@ def fake_github_api(method, endpoint, data=None): assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] -def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", lambda issue_number, actor: ("ok", True)) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - -def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.delenv("ISSUE_LABELS", raising=False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - - observed = {} - - def fake_handle(issue_number, actor): - observed["issue_number"] = issue_number - observed["actor"] = actor - observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) - return ("ok", True) - - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", fake_handle) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert observed == { - "issue_number": 42, - "actor": "alice", - "issue_labels": [reviewer_bot.FLS_AUDIT_LABEL], - } - assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - -def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["pending_privileged_commands"]["issue_comment:100"] = { - "source_event_key": "issue_comment:100", - "command_name": "accept-no-fls-changes", - "issue_number": 42, - "actor": "alice", - "status": "pending", - } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - called = {"handle": 0} - monkeypatch.setattr( - reviewer_bot, - "handle_accept_no_fls_changes_command", - lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), - ) - assert reviewer_bot.handle_manual_dispatch(state) is True - assert called["handle"] == 0 - pending = review["pending_privileged_commands"]["issue_comment:100"] - assert pending["status"] == "failed_closed" - assert pending["result"] == "live_revalidation_failed" - def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/unit/reviewer_bot/test_reconcile_replay.py b/tests/unit/reviewer_bot/test_reconcile_replay.py new file mode 100644 index 000000000..fb654cf88 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_replay.py @@ -0,0 +1,41 @@ +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, **kwargs: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: False, + ) + + message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( + reviewer_bot, + state, + 42, + require_pull_request_context=True, + ) + + assert success is True + assert changed is False + assert "no reconciliation transitions applied" in message From 0ef52a433ac8c15e29ef6a856d21b18a15adaab2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 23:23:25 +0900 Subject: [PATCH 048/209] test(reviewer-bot): move remaining sweeper reconcile tests --- .github/reviewer-bot-tests/test_reconcile.py | 44 +------------- tests/unit/reviewer_bot/test_sweeper_logic.py | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 43 deletions(-) diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py index d01fd05ab..083bf6cb6 100644 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ b/.github/reviewer-bot-tests/test_reconcile.py @@ -4,7 +4,7 @@ from factories import make_state from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing, sweeper +from scripts.reviewer_bot_lib import comment_routing def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): @@ -884,45 +884,3 @@ def fake_github_api(method, endpoint, data=None): assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] - -def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["reconciled_source_events"] = ["pull_request_review_dismissed:303"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - {"id": 303, "submitted_at": "2026-03-17T09:00:00Z", "updated_at": "2026-03-17T12:00:00Z", "state": "DISMISSED"}, - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False - assert state["active_reviews"]["42"]["deferred_gaps"] == {} - -def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["reconciled_source_events"] = ["issue_comment:101", "pull_request_review:202"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-17T10:00:00Z"}], - }.get(endpoint), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}]) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False - assert state["active_reviews"]["42"]["deferred_gaps"] == {} diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 239a81af8..b37e840c4 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -67,3 +67,63 @@ def fake_github_api(method, endpoint, data=None): gaps = state["active_reviews"]["42"]["deferred_gaps"] assert "pull_request_review_comment:404" in gaps assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" + + +def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-17T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["reconciled_source_events"] = ["pull_request_review_dismissed:303"] + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [], + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + {"id": 303, "submitted_at": "2026-03-17T09:00:00Z", "updated_at": "2026-03-17T12:00:00Z", "state": "DISMISSED"}, + ], + ) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} + + +def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-17T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["reconciled_source_events"] = ["issue_comment:101", "pull_request_review:202"] + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, + "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-17T10:00:00Z"}], + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}], + ) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert state["active_reviews"]["42"]["deferred_gaps"] == {} From 2dee442e511a4652d0e0750546d008bc386b3218 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 23:36:41 +0900 Subject: [PATCH 049/209] test(reviewer-bot): retire legacy reconcile file --- .github/reviewer-bot-tests/test_reconcile.py | 886 ------------------- 1 file changed, 886 deletions(-) delete mode 100644 .github/reviewer-bot-tests/test_reconcile.py diff --git a/.github/reviewer-bot-tests/test_reconcile.py b/.github/reviewer-bot-tests/test_reconcile.py deleted file mode 100644 index 083bf6cb6..000000000 --- a/.github/reviewer-bot-tests/test_reconcile.py +++ /dev/null @@ -1,886 +0,0 @@ -import json -import os - -from factories import make_state - -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing - - -def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "bob" - review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/11": - return { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert "pull_request_review:11" in review["reconciled_source_events"] - assert "pull_request_review:11" not in review["deferred_gaps"] - -def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:12", - "pr_number": 42, - "review_id": 12, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "pulls/42/reviews/12": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - - assert reviewer_bot.handle_workflow_run_event(state) is True - gap = review["deferred_gaps"]["pull_request_review:12"] - assert gap["reason"] == "reconcile_failed_closed" - assert gap["failure_kind"] == "server_error" - -def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /queue" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 610, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:210", - "pr_number": 42, - "comment_id": 210, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/210": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert "issue_comment:210" in review["reconciled_source_events"] - assert "issue_comment:210" not in review["deferred_gaps"] - -def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - payload_path = tmp_path / "deferred.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "APPROVED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False - -def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:99", - "pr_number": 42, - "review_id": 99, - "source_submitted_at": "2026-03-17T11:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-0", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/99": - return { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" - -def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:99", - "pr_number": 42, - "comment_id": 99, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ( - {"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None - ), - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" - -def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - live_body = "author reply in review thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 701, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:301", - "pr_number": 42, - "comment_id": 301, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - "actor_id": 5, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/301": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["contributor_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:301" - -def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - live_body = "reviewer reply in thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 702, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:302", - "pr_number": 42, - "comment_id": 302, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T11:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/302": - return { - "body": live_body, - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" - -def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 703, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:303", - "pr_number": 42, - "comment_id": 303, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" - assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" - -def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 601, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:199", - "pr_number": 42, - "comment_id": 199, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "601") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/199": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" - assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None - assert os.environ["IS_PULL_REQUEST"] == "true" - assert os.environ["ISSUE_AUTHOR"] == "dana" - assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] - -def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): - state = make_state() - state["queue"] = [{"github": "bob", "name": "Bob"}] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 602, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:200", - "pr_number": 42, - "comment_id": 200, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "602") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - assignment_calls = [] - removed_reviewers = [] - posted_comments = [] - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return { - "user": {"login": "dana"}, - "labels": [{"name": "coding guideline"}], - "requested_reviewers": [{"login": "alice"}], - } - if endpoint == "issues/comments/200": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - def fake_request(issue_number, username): - assignment_calls.append( - { - "issue_number": issue_number, - "username": username, - "is_pull_request": os.environ.get("IS_PULL_REQUEST"), - "issue_author": os.environ.get("ISSUE_AUTHOR"), - } - ) - return reviewer_bot.AssignmentAttempt(success=True, status_code=201) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", fake_request) - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: removed_reviewers.append((issue_number, username)) or True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted_comments.append((issue_number, body)) or True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert assignment_calls == [ - { - "issue_number": 42, - "username": "bob", - "is_pull_request": "true", - "issue_author": "dana", - } - ] - assert removed_reviewers == [(42, "alice")] - assert state["active_reviews"]["42"]["current_reviewer"] == "bob" - assert posted_comments - -def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" - live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:201", - "pr_number": 42, - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/201": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_only", - "has_non_command_text": False, - "command_count": 2, - "command": None, - "args": [], - "normalized_body": body, - }, - ) - command_calls = [] - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_handle_command", - lambda *args, **kwargs: command_calls.append("called") or True, - ) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert command_calls == [] - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" - assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] - -def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment-unavailable.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:205", - "pr_number": 42, - "comment_id": 205, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "issues/comments/205": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - - assert reviewer_bot.handle_workflow_run_event(state) is True - gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] - assert gap["reason"] == "reconcile_failed_closed" - assert gap["failure_kind"] == "server_error" - -def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 604, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:202", - "pr_number": 42, - "comment_id": 202, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "604") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/202": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_plus_text", - "has_non_command_text": True, - "command_count": 1, - "command": "claim", - "args": [], - "normalized_body": body, - }, - ) - - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" - assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" - assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] From 6a204ca18ac47fcd121592b6d546954ff8b41a4f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Wed, 1 Apr 2026 23:36:41 +0900 Subject: [PATCH 050/209] test(reviewer-bot): move additional sweeper logic tests --- .github/reviewer-bot-tests/test_sweeper.py | 58 -------------- tests/unit/reviewer_bot/test_sweeper_logic.py | 75 +++++++++++++++++++ 2 files changed, 75 insertions(+), 58 deletions(-) diff --git a/.github/reviewer-bot-tests/test_sweeper.py b/.github/reviewer-bot-tests/test_sweeper.py index 066a80c41..2d5e9b350 100644 --- a/.github/reviewer-bot-tests/test_sweeper.py +++ b/.github/reviewer-bot-tests/test_sweeper.py @@ -189,30 +189,6 @@ def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention() } assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" -def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: [ - { - "id": 100, - "created_at": "2026-03-25T10:00:00Z", - "user": {"login": "github-actions[bot]", "type": "Bot"}, - }, - { - "id": 101, - "created_at": "2026-03-25T11:00:00Z", - "user": {"login": "alice", "type": "User"}, - }, - ], - ) - discovered, complete = sweeper._discover_visible_comment_events(reviewer_bot, 42, review) - assert complete is True - assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] - def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): run_correlation = { "candidate_run_ids": [123], @@ -224,40 +200,6 @@ def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_matc assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} assert run_correlation["correlated_run"] == 123 -def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else {"workflow_runs": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 202, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True - assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - assert "pull_request_review:202" not in review["deferred_gaps"] - assert "pull_request_review:202" in review["reconciled_source_events"] - def test_workflow_policy_split_and_lock_only_boundaries(): workflows_dir = Path(".github/workflows") required = { diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index b37e840c4..93e18b24c 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -127,3 +127,78 @@ def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} + + +def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: [ + { + "id": 100, + "created_at": "2026-03-25T10:00:00Z", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + { + "id": 101, + "created_at": "2026-03-25T11:00:00Z", + "user": {"login": "alice", "type": "User"}, + }, + ], + ) + + discovered, complete = sweeper._discover_visible_comment_events(reviewer_bot, 42, review) + + assert complete is True + assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] + + +def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else {"workflow_runs": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 202, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + assert "pull_request_review:202" not in review["deferred_gaps"] + assert "pull_request_review:202" in review["reconciled_source_events"] From 6460a6b2218a75d36e67298a58b49dd62a37cd7c Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 01:04:33 +0900 Subject: [PATCH 051/209] test(reviewer-bot): split sweeper download tests --- .github/reviewer-bot-tests/test_sweeper.py | 77 +------------------ .../reviewer_bot/test_sweeper_downloads.py | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 76 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_sweeper_downloads.py diff --git a/.github/reviewer-bot-tests/test_sweeper.py b/.github/reviewer-bot-tests/test_sweeper.py index 2d5e9b350..fddb9d330 100644 --- a/.github/reviewer-bot-tests/test_sweeper.py +++ b/.github/reviewer-bot-tests/test_sweeper.py @@ -3,8 +3,7 @@ import pytest import yaml -from factories import make_state, make_zip_payload -from fakes import FakeGitHubResponse +from factories import make_state from scripts import reviewer_bot from scripts.reviewer_bot_lib import sweeper @@ -298,80 +297,6 @@ def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency( for job in data.get("jobs", {}).values(): assert "concurrency" not in job -def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): - payload = {"source_event_key": "issue_comment:100"} - responses = iter( - [ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ] - ) - success_response = FakeGitHubResponse(200, None, "") - success_response.content = make_zip_payload("deferred-comment.json", payload) - - def fake_request(*args, **kwargs): - response = next(responses, success_response) - return response - - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr(sweeper.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, artifact_payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "ok" - assert artifact_payload == payload - -def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: (_ for _ in ()).throw( - sweeper.requests.RequestException("timeout") - ), - ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "download_unavailable" - assert payload is None - -def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) - - status, payload = sweeper._download_artifact_payload( - reviewer_bot, - {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, - "deferred-comment.json", - ) - - assert status == "download_unavailable" - assert payload is None - -def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): - monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) - - assert sweeper._list_run_artifacts(reviewer_bot, 42) is None - - @pytest.mark.parametrize( ("workflow_path", "artifact_name", "payload_name"), [ diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py new file mode 100644 index 000000000..878930ada --- /dev/null +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -0,0 +1,77 @@ +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import sweeper +from tests.fixtures.github import FakeGitHubResponse +from tests.fixtures.reviewer_bot import make_zip_payload + + +def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): + payload = {"source_event_key": "issue_comment:100"} + responses = iter([ + FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ]) + success_response = FakeGitHubResponse(200, None, "") + success_response.content = make_zip_payload("deferred-comment.json", payload) + + def fake_request(*args, **kwargs): + response = next(responses, success_response) + return response + + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(sweeper.requests, "request", fake_request) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, artifact_payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "ok" + assert artifact_payload == payload + + +def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: (_ for _ in ()).throw(sweeper.requests.RequestException("timeout")), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + + +def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + sweeper.requests, + "request", + lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + ) + monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + + status, payload = sweeper._download_artifact_payload( + reviewer_bot, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + assert status == "download_unavailable" + assert payload is None + + +def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): + monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) + + assert sweeper._list_run_artifacts(reviewer_bot, 42) is None From 8d96d6343d54a4931070b421467094b9bc746770 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:13:07 +0900 Subject: [PATCH 052/209] fix(reviewer-bot): stop stale replay from resetting reminders --- .github/reviewer-bot-tests/test_reviews.py | 4 +- scripts/reviewer_bot_lib/reviews.py | 10 ++- .../reviewer_bot/test_app_schedule_repairs.py | 81 +++++++++++++++++++ tests/unit/reviewer_bot/test_overdue.py | 59 ++++++++++++++ tests/unit/reviewer_bot/test_sweeper_logic.py | 42 ++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) diff --git a/.github/reviewer-bot-tests/test_reviews.py b/.github/reviewer-bot-tests/test_reviews.py index ab1455806..e21031587 100644 --- a/.github/reviewer-bot-tests/test_reviews.py +++ b/.github/reviewer-bot-tests/test_reviews.py @@ -405,8 +405,8 @@ def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill( review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None + assert review["transition_warning_sent"] == "2026-03-21T10:00:00Z" + assert review["transition_notice_sent_at"] == "2026-03-22T10:00:00Z" def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): state = make_state() diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index e8d7a4ec7..c61005954 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -582,12 +582,16 @@ def clear_transition_timers(review_data: dict) -> None: review_data["transition_notice_sent_at"] = None -def record_reviewer_activity(review_data: dict, timestamp: str) -> None: +def record_reviewer_activity(review_data: dict, timestamp: str) -> bool: current = parse_github_timestamp(review_data.get("last_reviewer_activity")) candidate = parse_github_timestamp(timestamp) - if current is None or candidate is None or candidate >= current: + if candidate is None: + return False + if current is None or candidate > current: review_data["last_reviewer_activity"] = timestamp - clear_transition_timers(review_data) + clear_transition_timers(review_data) + return True + return False def record_transition_notice_sent(review_data: dict, timestamp: str) -> None: diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py index fd6262739..767866141 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_repairs.py +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -124,3 +124,84 @@ def fake_save_state(updated_state): assert result.exit_code == 0 assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" assert len(saved_states) >= 2 + + +def test_schedule_overdue_check_does_not_repeat_warning_after_stale_review_repair(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "iglesias" + review["assigned_at"] = "2026-02-26T04:58:03Z" + review["active_cycle_started_at"] = "2026-02-26T04:58:03Z" + review["last_reviewer_activity"] = "2026-03-18T01:09:05Z" + review["transition_warning_sent"] = "2026-04-01T12:12:04Z" + + saved_warning_values = [] + posted_comments = [] + + def fake_load_state(*args, **kwargs): + return state + + def fake_sweep(bot, current): + bot.reviews_module.record_reviewer_activity( + current["active_reviews"]["42"], + "2026-03-18T01:09:05Z", + ) + return False + + 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", fake_load_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.maintenance_module, "sweep_deferred_gaps", fake_sweep) + monkeypatch.setattr( + reviewer_bot, + "get_issue_or_pr_snapshot", + lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "repair_missing_reviewer_review_state", + lambda bot, issue_number, review_data: False, + ) + monkeypatch.setattr( + reviewer_bot.maintenance_module, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "compute_reviewer_response_state", + lambda bot, issue_number, review_data, **kwargs: { + "state": "awaiting_reviewer_response", + "reason": "review_head_stale", + "anchor_timestamp": "2026-03-18T12:09:36Z", + }, + ) + monkeypatch.setattr( + reviewer_bot, + "post_comment", + lambda issue_number, body: posted_comments.append((issue_number, body)) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: saved_warning_values.append(current["active_reviews"]["42"]["transition_warning_sent"]) + or True, + ) + monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: False) + + first = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + second = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert posted_comments == [] + assert review["transition_warning_sent"] == "2026-04-01T12:12:04Z" + assert saved_warning_values == [] diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py index 446358a91..27f0d030b 100644 --- a/tests/unit/reviewer_bot/test_overdue.py +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -248,3 +248,62 @@ def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_ ) assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + + +def test_refresh_reviewer_review_from_live_preferred_review_does_not_clear_transition_warning_when_activity_not_advanced( + monkeypatch, +): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["reviewer_review"] = { + "accepted": { + "semantic_key": "pull_request_review:10", + "timestamp": "2026-03-17T10:01:00Z", + "actor": "alice", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + }, + "seen_keys": ["pull_request_review:10"], + } + review["last_reviewer_activity"] = "2026-03-17T10:01:00Z" + review["transition_warning_sent"] = "2026-04-01T12:12:04Z" + review["transition_notice_sent_at"] = "2026-04-15T12:12:04Z" + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ), + ) + + changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( + reviewer_bot, + 42, + review, + ) + + assert changed is False + assert preferred_review is not None + assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" + assert review["transition_warning_sent"] == "2026-04-01T12:12:04Z" + assert review["transition_notice_sent_at"] == "2026-04-15T12:12:04Z" diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 93e18b24c..b66884d12 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -202,3 +202,45 @@ def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_witho assert review["transition_notice_sent_at"] is None assert "pull_request_review:202" not in review["deferred_gaps"] assert "pull_request_review:202" in review["reconciled_source_events"] + + +def test_visible_review_repair_does_not_clear_transition_warning_for_stale_replayed_review(monkeypatch): + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), + ) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["last_reviewer_activity"] = "2026-03-25T11:00:00Z" + review["transition_warning_sent"] = "2026-04-01T12:12:04Z" + review["transition_notice_sent_at"] = "2026-04-15T12:12:04Z" + review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} + if endpoint == "pulls/42" + else {"workflow_runs": []}, + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 202, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" + assert review["transition_warning_sent"] == "2026-04-01T12:12:04Z" + assert review["transition_notice_sent_at"] == "2026-04-15T12:12:04Z" From 6771bbba6c440fd7dd6cb1483afb1388b6d2fb2a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:20:25 +0900 Subject: [PATCH 053/209] test(reviewer-bot): retire legacy sweeper file --- .github/reviewer-bot-tests/test_sweeper.py | 331 ------------------ .../reviewer_bot/test_sweeper_downloads.py | 19 + tests/unit/reviewer_bot/test_sweeper_logic.py | 203 +++++++++++ 3 files changed, 222 insertions(+), 331 deletions(-) delete mode 100644 .github/reviewer-bot-tests/test_sweeper.py diff --git a/.github/reviewer-bot-tests/test_sweeper.py b/.github/reviewer-bot-tests/test_sweeper.py deleted file mode 100644 index fddb9d330..000000000 --- a/.github/reviewer-bot-tests/test_sweeper.py +++ /dev/null @@ -1,331 +0,0 @@ -import os -from pathlib import Path - -import pytest -import yaml -from factories import make_state - -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import sweeper - - -def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot.reviews_module, - "accept_reviewer_review_from_live_review", - lambda review_data, live_review, actor=None: False, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, actor=None: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) - - changed = sweeper._repair_visible_review_gap( - reviewer_bot, - review, - 42, - "pull_request_review:303", - { - "id": 303, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - ) - - assert changed is True - assert "pull_request_review:303" in review["reconciled_source_events"] - assert "pull_request_review:303" not in review["deferred_gaps"] - -def test_observer_run_reason_mapping_and_near_miss_signature(): - signature = {"status": "waiting", "conclusion": None, "name": "approval_pending"} - assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "approval_pending"}, signature) == "awaiting_observer_approval" - assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "almost"}, signature) == "observer_state_unknown" - -def test_negative_missing_run_requires_full_scan_and_recheck(): - gap = { - "source_event_created_at": "2026-03-15T00:00:00Z", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run_found": False, - "approval_pending_evidence_retained": False, - } - assert sweeper.can_mark_observer_run_missing(gap) is True - gap["later_recheck_complete"] = False - assert sweeper.can_mark_observer_run_missing(gap) is False - -def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_window(): - os.environ["GITHUB_REPOSITORY"] = "rustfoundation/safety-critical-rust-coding-guidelines" - result = sweeper.correlate_candidate_observer_runs( - "issue_comment:101", - source_event_kind="issue_comment:created", - source_event_created_at="2026-03-17T10:00:00Z", - pr_number=42, - workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", - workflow_runs=[ - { - "id": 1, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:05:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, - { - "id": 2, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:40:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, - ], - ) - assert result["candidate_run_ids"] == [1] - -def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): - result = sweeper.correlate_run_artifacts_exact( - { - 10: [{"source_event_key": "issue_comment:101", "source_run_id": 10, "source_run_attempt": 1, "pr_number": 42}], - 11: [{"source_event_key": "issue_comment:101", "source_run_id": 11, "source_run_attempt": 1, "pr_number": 42}], - }, - "issue_comment:101", - pr_number=42, - ) - assert result["status"] == "observer_state_unknown" - assert result["reason"] == "ambiguous_exact_artifact_matches" - -def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"artifacts": [{"id": 1, "name": "artifact"}]}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ), - ) - - assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] - -def test_evaluate_gap_state_treats_artifact_download_unavailable_as_unknown(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "download_unavailable"}}, - ) - assert reason == "observer_state_unknown" - assert diagnostic == "artifact_download_unavailable" - -def test_evaluate_gap_state_only_emits_missing_after_negative_inference_contract(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - { - "source_event_created_at": "2026-03-15T00:00:00Z", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run_found": False, - "approval_pending_evidence_retained": False, - }, - { - "status": "no_candidate_runs", - "full_scan_complete": True, - "later_recheck_complete": True, - "correlated_run": None, - }, - None, - None, - ) - assert reason == "observer_run_missing" - assert diagnostic == "negative_inference_satisfied" - -def test_evaluate_gap_state_completed_success_without_exact_artifact_is_artifact_missing(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "reason": "no_exact_source_event_key_match"}, - ) - assert reason == "artifact_missing" - assert diagnostic == "no_exact_source_event_key_match" - -def test_evaluate_gap_state_completed_success_with_expired_artifact_marks_artifact_expired(): - reason, diagnostic = sweeper.evaluate_deferred_gap_state( - {"source_event_created_at": "2026-03-17T00:00:00Z"}, - {"status": "candidate_runs_found", "correlated_run": 10}, - {"status": "completed", "conclusion": "success"}, - {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "expired"}}, - ) - assert reason == "artifact_expired" - assert diagnostic == "prior_visibility_or_retention_proof_required" - -def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention(): - expired = { - "artifact_seen_at": "2026-03-10T00:00:00Z", - "run_created_at": "2026-03-10T00:00:00Z", - } - assert sweeper.classify_artifact_gap_reason(expired) == "artifact_expired" - missing = { - "artifact_inspection_complete": True, - "run_created_at": "2026-03-17T00:00:00Z", - } - assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" - -def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): - run_correlation = { - "candidate_run_ids": [123], - "correlated_run": None, - "correlated_run_found": False, - } - monkeypatch.setattr(sweeper, "_fetch_run_detail", lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}) - detail = sweeper._maybe_fetch_single_candidate_run_detail(reviewer_bot, run_correlation, {"status": "no_exact_artifact_match"}) - assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} - assert run_correlation["correlated_run"] == 123 - -def test_workflow_policy_split_and_lock_only_boundaries(): - workflows_dir = Path(".github/workflows") - required = { - "reviewer-bot-issues.yml", - "reviewer-bot-issue-comment-direct.yml", - "reviewer-bot-sweeper-repair.yml", - "reviewer-bot-pr-metadata.yml", - "reviewer-bot-pr-comment-trusted.yml", - "reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-reconcile.yml", - "reviewer-bot-privileged-commands.yml", - } - assert required.issubset({path.name for path in workflows_dir.glob("reviewer-bot-*.yml")}) - for path in required: - data = yaml.safe_load((workflows_dir / path).read_text(encoding="utf-8")) - jobs = data.get("jobs", {}) - for job in jobs.values(): - permissions = job.get("permissions", {}) - steps = job.get("steps", []) - uses_values = [step.get("uses", "") for step in steps if isinstance(step, dict)] - text = (workflows_dir / path).read_text(encoding="utf-8") - if "observer" in path: - assert permissions.get("contents") == "read" - assert all("checkout" not in value for value in uses_values) - if permissions.get("contents") == "write" and path != "reviewer-bot-privileged-commands.yml": - assert all("checkout" not in value for value in uses_values) - assert "Temporary lock debt" in text - for value in uses_values: - if value: - assert "@" in value and len(value.split("@", 1)[1]) == 40 - -def test_pr_comment_observer_workflow_builds_payload_inline_without_bot_src_root(): - workflow = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") - assert "BOT_SRC_ROOT" not in workflow - assert "build_pr_comment_observer_payload" not in workflow - assert "Fetch trusted bot source tarball" not in workflow - -def test_workflow_summaries_and_runbook_references_exist(): - runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md") - assert runbook.exists() - reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") - assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile - -def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8")) - job = data["jobs"]["reviewer-bot-pr-comment-trusted"] - steps = job["steps"] - assert steps[0]["name"] == "Decide whether same-repo trusted path applies" - assert steps[1]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[2]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[3]["if"] == "env.RUN_TRUSTED_PR_COMMENT == 'true'" - assert steps[4]["name"] == "Trusted path skipped" - assert steps[4]["if"] == "env.RUN_TRUSTED_PR_COMMENT != 'true'" - workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8") - assert "https://api.github.com/repos/{repo}/pulls/{pr_number}" in workflow_text - assert "RUN_TRUSTED_PR_COMMENT" in workflow_text - -def test_pr_comment_observer_workflow_uses_inline_payload_builder(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8")) - job = data["jobs"]["observer"] - steps = job["steps"] - assert steps[0]["name"] == "Build deferred comment artifact" - assert steps[1]["name"] == "Upload deferred comment artifact" - workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") - assert "build_pr_comment_observer_payload" not in workflow_text - assert 'uv run --project "$BOT_SRC_ROOT"' not in workflow_text - -def test_review_comment_observer_workflow_exists_and_is_read_only(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8")) - on_block = data.get("on", data.get(True)) - assert on_block["pull_request_review_comment"]["types"] == ["created"] - job = data["jobs"]["observer"] - assert job["permissions"]["contents"] == "read" - steps = job["steps"] - assert steps[0]["name"] == "Build deferred review comment artifact" - assert steps[1]["name"] == "Upload deferred review comment artifact" - workflow_text = Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8") - assert "checkout" not in workflow_text - assert "pull_request_review_comment" in workflow_text - -def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency(): - workflow_paths = [ - ".github/workflows/reviewer-bot-issues.yml", - ".github/workflows/reviewer-bot-issue-comment-direct.yml", - ".github/workflows/reviewer-bot-sweeper-repair.yml", - ".github/workflows/reviewer-bot-pr-metadata.yml", - ".github/workflows/reviewer-bot-pr-comment-trusted.yml", - ".github/workflows/reviewer-bot-reconcile.yml", - ".github/workflows/reviewer-bot-privileged-commands.yml", - ] - for workflow_path in workflow_paths: - data = yaml.safe_load(Path(workflow_path).read_text(encoding="utf-8")) - for job in data.get("jobs", {}).values(): - assert "concurrency" not in job - -@pytest.mark.parametrize( - ("workflow_path", "artifact_name", "payload_name"), - [ - ( - ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-comment.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "reviewer-bot-review-submitted-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-submitted.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", - "reviewer-bot-review-dismissed-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-dismissed.json", - ), - ( - ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "reviewer-bot-review-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }}", - "deferred-review-comment.json", - ), - ], -) -def test_observer_workflow_files_match_expected_artifact_contract( - workflow_path, artifact_name, payload_name -): - workflow_text = Path(workflow_path).read_text(encoding="utf-8") - - assert artifact_name in workflow_text - assert payload_name in workflow_text diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index 878930ada..c39da24e1 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -75,3 +75,22 @@ def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatc monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) assert sweeper._list_run_artifacts(reviewer_bot, 42) is None + + +def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): + monkeypatch.setattr( + reviewer_bot, + "github_api_request", + lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( + status_code=200, + payload={"artifacts": [{"id": 1, "name": "artifact"}]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ), + ) + + assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index b66884d12..a0dbcb38e 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -244,3 +244,206 @@ def test_visible_review_repair_does_not_clear_transition_warning_for_stale_repla assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" assert review["transition_warning_sent"] == "2026-04-01T12:12:04Z" assert review["transition_notice_sent_at"] == "2026-04-15T12:12:04Z" + + +def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): + review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} + monkeypatch.setattr( + reviewer_bot.reviews_module, + "accept_reviewer_review_from_live_review", + lambda review_data, live_review, actor=None: False, + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "refresh_reviewer_review_from_live_preferred_review", + lambda bot, issue_number, review_data, actor=None: (False, None), + ) + monkeypatch.setattr( + reviewer_bot.reviews_module, + "rebuild_pr_approval_state", + lambda bot, issue_number, review_data: (None, None), + ) + + changed = sweeper._repair_visible_review_gap( + reviewer_bot, + review, + 42, + "pull_request_review:303", + { + "id": 303, + "submitted_at": "2026-03-25T11:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + ) + + assert changed is True + assert "pull_request_review:303" in review["reconciled_source_events"] + assert "pull_request_review:303" not in review["deferred_gaps"] + + +def test_observer_run_reason_mapping_and_near_miss_signature(): + signature = {"status": "waiting", "conclusion": None, "name": "approval_pending"} + assert ( + sweeper.observer_run_reason_from_details( + {"status": "waiting", "conclusion": None, "name": "approval_pending"}, + signature, + ) + == "awaiting_observer_approval" + ) + assert ( + sweeper.observer_run_reason_from_details( + {"status": "waiting", "conclusion": None, "name": "almost"}, + signature, + ) + == "observer_state_unknown" + ) + + +def test_negative_missing_run_requires_full_scan_and_recheck(): + gap = { + "source_event_created_at": "2026-03-15T00:00:00Z", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run_found": False, + "approval_pending_evidence_retained": False, + } + assert sweeper.can_mark_observer_run_missing(gap) is True + gap["later_recheck_complete"] = False + assert sweeper.can_mark_observer_run_missing(gap) is False + + +def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_window(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + result = sweeper.correlate_candidate_observer_runs( + "issue_comment:101", + source_event_kind="issue_comment:created", + source_event_created_at="2026-03-17T10:00:00Z", + pr_number=42, + workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", + workflow_runs=[ + { + "id": 1, + "event": "issue_comment", + "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "created_at": "2026-03-17T10:05:00Z", + "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, + "pull_requests": [{"number": 42}], + }, + { + "id": 2, + "event": "issue_comment", + "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "created_at": "2026-03-17T10:40:00Z", + "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, + "pull_requests": [{"number": 42}], + }, + ], + ) + assert result["candidate_run_ids"] == [1] + + +def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): + result = sweeper.correlate_run_artifacts_exact( + { + 10: [{"source_event_key": "issue_comment:101", "source_run_id": 10, "source_run_attempt": 1, "pr_number": 42}], + 11: [{"source_event_key": "issue_comment:101", "source_run_id": 11, "source_run_attempt": 1, "pr_number": 42}], + }, + "issue_comment:101", + pr_number=42, + ) + assert result["status"] == "observer_state_unknown" + assert result["reason"] == "ambiguous_exact_artifact_matches" + + +def test_evaluate_gap_state_treats_artifact_download_unavailable_as_unknown(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "download_unavailable"}}, + ) + assert reason == "observer_state_unknown" + assert diagnostic == "artifact_download_unavailable" + + +def test_evaluate_gap_state_only_emits_missing_after_negative_inference_contract(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + { + "source_event_created_at": "2026-03-15T00:00:00Z", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run_found": False, + "approval_pending_evidence_retained": False, + }, + { + "status": "no_candidate_runs", + "full_scan_complete": True, + "later_recheck_complete": True, + "correlated_run": None, + }, + None, + None, + ) + assert reason == "observer_run_missing" + assert diagnostic == "negative_inference_satisfied" + + +def test_evaluate_gap_state_completed_success_without_exact_artifact_is_artifact_missing(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "reason": "no_exact_source_event_key_match"}, + ) + assert reason == "artifact_missing" + assert diagnostic == "no_exact_source_event_key_match" + + +def test_evaluate_gap_state_completed_success_with_expired_artifact_marks_artifact_expired(): + reason, diagnostic = sweeper.evaluate_deferred_gap_state( + {"source_event_created_at": "2026-03-17T00:00:00Z"}, + {"status": "candidate_runs_found", "correlated_run": 10}, + {"status": "completed", "conclusion": "success"}, + {"status": "no_exact_artifact_match", "artifact_scan_outcomes": {10: "expired"}}, + ) + assert reason == "artifact_expired" + assert diagnostic == "prior_visibility_or_retention_proof_required" + + +def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention(): + expired = { + "artifact_seen_at": "2026-03-10T00:00:00Z", + "run_created_at": "2026-03-10T00:00:00Z", + } + assert sweeper.classify_artifact_gap_reason(expired) == "artifact_expired" + missing = { + "artifact_inspection_complete": True, + "run_created_at": "2026-03-17T00:00:00Z", + } + assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" + + +def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): + run_correlation = { + "candidate_run_ids": [123], + "correlated_run": None, + "correlated_run_found": False, + } + monkeypatch.setattr( + sweeper, + "_fetch_run_detail", + lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}, + ) + detail = sweeper._maybe_fetch_single_candidate_run_detail( + reviewer_bot, + run_correlation, + {"status": "no_exact_artifact_match"}, + ) + assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} + assert run_correlation["correlated_run"] == 123 From ca2d189cbe894d5735036269348bae7b10ae78c4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:22:34 +0900 Subject: [PATCH 054/209] test(reviewer-bot): retire legacy comment routing file --- .../reviewer_bot/test_workflow_files.py | 32 ++++ .../test_comment_routing_integration.py | 144 +++--------------- .../unit/reviewer_bot/test_comment_routing.py | 98 ++++++++++++ 3 files changed, 148 insertions(+), 126 deletions(-) rename .github/reviewer-bot-tests/test_comment_routing.py => tests/integration/reviewer_bot/test_comment_routing_integration.py (54%) create mode 100644 tests/unit/reviewer_bot/test_comment_routing.py diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py index c77f449bf..6e7901d81 100644 --- a/tests/contract/reviewer_bot/test_workflow_files.py +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -117,3 +117,35 @@ def test_workflow_summaries_and_runbook_references_exist(): assert runbook.exists() reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile + + +def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): + from scripts import reviewer_bot + + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "PLeVasseur") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") + monkeypatch.setenv("COMMENT_SENDER_TYPE", "User") + monkeypatch.setenv("COMMENT_INSTALLATION_ID", "") + monkeypatch.setenv("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") + monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") + monkeypatch.setenv("COMMENT_ID", "100") + monkeypatch.setenv("COMMENT_AUTHOR_ID", "123") + monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") + monkeypatch.setenv("GITHUB_RUN_ID", "999") + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "PLeVasseur"}, + }, + ) + + payload = reviewer_bot.build_pr_comment_observer_payload(42) + + assert payload["kind"] == "observer_noop" + assert payload["reason"] == "trusted_direct_same_repo_human_comment" + assert payload["source_event_key"] == "issue_comment:100" diff --git a/.github/reviewer-bot-tests/test_comment_routing.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py similarity index 54% rename from .github/reviewer-bot-tests/test_comment_routing.py rename to tests/integration/reviewer_bot/test_comment_routing_integration.py index 77703211a..3a30dd62a 100644 --- a/.github/reviewer-bot-tests/test_comment_routing.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -1,101 +1,8 @@ import json -from pathlib import Path - -import pytest -from factories import make_state from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing - - -def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:100", - timestamp="2026-03-17T09:00:00Z", - actor="alice", - ) - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") - - changed = reviewer_bot.comment_routing_module._record_conversation_freshness( - reviewer_bot, - state, - 42, - "alice", - 100, - "2026-03-17T10:00:00Z", - ) +from tests.fixtures.reviewer_bot import make_state - assert changed is True - assert review["last_reviewer_activity"] == "2026-03-17T10:00:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - - -@pytest.mark.parametrize( - ("env", "expected"), - [ - ({"COMMENT_USER_TYPE": "Bot", "COMMENT_AUTHOR": "reviewer-bot"}, "bot_account"), - ({"COMMENT_INSTALLATION_ID": "12345"}, "github_app_or_other_automation"), - ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice"}, "repo_user_principal"), - ({"COMMENT_AUTHOR": "mystery", "COMMENT_USER_TYPE": ""}, "unknown_actor"), - ], -) -def test_classify_issue_comment_actor(monkeypatch, env, expected): - for key, value in env.items(): - monkeypatch.setenv(key, value) - assert comment_routing.classify_issue_comment_actor() == expected - -def test_classify_comment_payload_distinguishes_command_plus_text(): - payload = comment_routing.classify_comment_payload(reviewer_bot, "hello\n@guidelines-bot /queue") - assert payload["comment_class"] == "command_plus_text" - assert payload["has_non_command_text"] is True - -def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, - ) - assert comment_routing.route_issue_comment_trust(reviewer_bot, 42) == "pr_trusted_direct" - -def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, - ) - with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): - comment_routing.route_issue_comment_trust(reviewer_bot, 42) def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): state = make_state() @@ -114,11 +21,13 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypa monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + assert reviewer_bot.handle_comment_event(state) is True pending = state["active_reviews"]["42"]["pending_privileged_commands"] assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" assert pending["issue_comment:100"]["authorization"]["authorized"] is True + def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -130,9 +39,11 @@ def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypat monkeypatch.setenv("COMMENT_ID", "100") monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + assert reviewer_bot.handle_comment_event(state) is False assert state["active_reviews"] == {} + def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -148,11 +59,17 @@ def test_closed_non_pr_command_comment_does_not_create_pending_privileged_comman monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: called.__setitem__("post_comment", called["post_comment"] + 1) or True) + monkeypatch.setattr( + reviewer_bot, + "post_comment", + lambda *args, **kwargs: called.__setitem__("post_comment", called["post_comment"] + 1) or True, + ) + assert reviewer_bot.handle_comment_event(state) is False assert state["active_reviews"] == {} assert called["post_comment"] == 0 + def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -167,9 +84,11 @@ def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): monkeypatch.setenv("COMMENT_ID", "100") monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + assert reviewer_bot.handle_comment_event(state) is True assert "42" not in state["active_reviews"] + def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -185,6 +104,7 @@ def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): assert reviewer_bot.handle_comment_event(state) is False assert state["active_reviews"] == {} + def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -199,10 +119,12 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): monkeypatch.setenv("COMMENT_ID", "100") monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: contributor plain text comment") + assert reviewer_bot.handle_comment_event(state) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted["semantic_key"] == "issue_comment:100" + def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): state = make_state() reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -230,36 +152,6 @@ def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "777") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + assert reviewer_bot.handle_workflow_run_event(state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} - -def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "PLeVasseur") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") - monkeypatch.setenv("COMMENT_SENDER_TYPE", "User") - monkeypatch.setenv("COMMENT_INSTALLATION_ID", "") - monkeypatch.setenv("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_AUTHOR_ID", "123") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") - monkeypatch.setenv("GITHUB_RUN_ID", "999") - monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "PLeVasseur"}, - }, - ) - payload = reviewer_bot.build_pr_comment_observer_payload(42) - assert payload["kind"] == "observer_noop" - assert payload["reason"] == "trusted_direct_same_repo_human_comment" - assert payload["source_event_key"] == "issue_comment:100" - -def test_issue_comment_direct_workflow_exports_issue_state(): - workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text(encoding="utf-8") - assert "ISSUE_STATE: ${{ github.event.issue.state }}" in workflow_text diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py new file mode 100644 index 000000000..812c5a4e0 --- /dev/null +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -0,0 +1,98 @@ +import pytest + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing +from tests.fixtures.reviewer_bot import make_state + + +def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + reviewer_bot.reviews_module.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T09:00:00Z", + actor="alice", + ) + monkeypatch.setenv("ISSUE_AUTHOR", "dana") + monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") + + changed = reviewer_bot.comment_routing_module._record_conversation_freshness( + reviewer_bot, + state, + 42, + "alice", + 100, + "2026-03-17T10:00:00Z", + ) + + assert changed is True + assert review["last_reviewer_activity"] == "2026-03-17T10:00:00Z" + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({"COMMENT_USER_TYPE": "Bot", "COMMENT_AUTHOR": "reviewer-bot"}, "bot_account"), + ({"COMMENT_INSTALLATION_ID": "12345"}, "github_app_or_other_automation"), + ({"COMMENT_USER_TYPE": "User", "COMMENT_AUTHOR": "alice"}, "repo_user_principal"), + ({"COMMENT_AUTHOR": "mystery", "COMMENT_USER_TYPE": ""}, "unknown_actor"), + ], +) +def test_classify_issue_comment_actor(monkeypatch, env, expected): + for key, value in env.items(): + monkeypatch.setenv(key, value) + assert comment_routing.classify_issue_comment_actor() == expected + + +def test_classify_comment_payload_distinguishes_command_plus_text(): + payload = comment_routing.classify_comment_payload(reviewer_bot, "hello\n@guidelines-bot /queue") + assert payload["comment_class"] == "command_plus_text" + assert payload["has_non_command_text"] is True + + +def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("COMMENT_USER_TYPE", "User") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "carol"}, + }, + ) + assert comment_routing.route_issue_comment_trust(reviewer_bot, 42) == "pr_trusted_direct" + + +def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): + monkeypatch.setenv("IS_PULL_REQUEST", "true") + monkeypatch.setenv("COMMENT_USER_TYPE", "") + monkeypatch.setenv("COMMENT_AUTHOR", "alice") + monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "carol"}, + }, + ) + with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): + comment_routing.route_issue_comment_trust(reviewer_bot, 42) From 1830a71f86e3b8139777fac3b6b45b4acdf72440 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:26:55 +0900 Subject: [PATCH 055/209] test(reviewer-bot): retire legacy commands file --- .../test_accept_no_fls_changes.py | 143 +++++++++++++ .../unit/reviewer_bot}/test_commands.py | 191 ++++++------------ 2 files changed, 203 insertions(+), 131 deletions(-) create mode 100644 tests/integration/reviewer_bot/test_accept_no_fls_changes.py rename {.github/reviewer-bot-tests => tests/unit/reviewer_bot}/test_commands.py (64%) diff --git a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py new file mode 100644 index 000000000..4b7e920c9 --- /dev/null +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -0,0 +1,143 @@ +import json +import subprocess + +import pytest + +from builder import build_cli +from scripts import reviewer_bot + + +def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): + commands_seen = [] + + def fake_run_command(command, cwd, check=True): + commands_seen.append(command) + if command == ["git", "diff", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if command == ["git", "diff", "--cached", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) + + assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] + assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] + + +def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): + def fake_run_command(command, cwd, check=True): + if command == ["git", "diff", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="README.md\nsrc/spec.lock\n", stderr="") + if command == ["git", "diff", "--cached", "--name-only"]: + return subprocess.CompletedProcess(command, 0, stdout="src/spec.lock\n", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) + + assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] + + +def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + observed = {"cwd": None} + + def fake_list_changed_files(repo_root): + observed["cwd"] = repo_root + return ["README.md"] + + monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + + assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) + assert observed["cwd"] == tmp_path + + +def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + list_calls = {"count": 0} + + def fake_list_changed_files(repo_root): + list_calls["count"] += 1 + assert repo_root == tmp_path + return [] + + commands = [] + + def fake_run_command(command, cwd, check=False): + commands.append((command, cwd, check)) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) + + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + + assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) + assert list_calls["count"] == 2 + assert commands == [ + (["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], tmp_path, False), + (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), + ] + + +def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): + monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) + monkeypatch.setenv("IS_PULL_REQUEST", "false") + monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) + + def fake_run_command(command, cwd, check=False): + return subprocess.CompletedProcess( + command, + 1, + stdout="", + stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", + ) + + monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) + + message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + + assert success is False + assert "Audit command failed." in message + assert "--locked was provided" in message + + +def test_update_spec_lock_file_mode_exits_before_build_docs(monkeypatch, tmp_path): + monkeypatch.setattr( + build_cli.argparse.ArgumentParser, + "parse_args", + lambda self: type( + "Args", + (), + { + "clear": False, + "offline": False, + "ignore_spec_lock_diff": False, + "update_spec_lock_file": True, + "validate_urls": False, + "serve": False, + "check_links": False, + "xml": False, + "verbose": False, + "debug": False, + }, + )(), + ) + called = {"update": 0, "build": 0} + monkeypatch.setattr(build_cli, "update_spec_lockfile", lambda url, path: called.__setitem__("update", called["update"] + 1) or True) + monkeypatch.setattr(build_cli, "build_docs", lambda *args, **kwargs: called.__setitem__("build", called["build"] + 1)) + + with pytest.raises(SystemExit) as exc_info: + build_cli.main(tmp_path) + + assert exc_info.value.code == 0 + assert called == {"update": 1, "build": 0} diff --git a/.github/reviewer-bot-tests/test_commands.py b/tests/unit/reviewer_bot/test_commands.py similarity index 64% rename from .github/reviewer-bot-tests/test_commands.py rename to tests/unit/reviewer_bot/test_commands.py index 2d85d9469..fff6acb0b 100644 --- a/.github/reviewer-bot-tests/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -1,13 +1,10 @@ import json -import subprocess -from pathlib import Path import pytest -from factories import make_state -from builder import build_cli from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing +from tests.fixtures.reviewer_bot import make_state def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): @@ -28,18 +25,18 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat monkeypatch.setattr( reviewer_bot, "sync_status_labels_for_items", - lambda *args, **kwargs: pytest.fail( - "status sync should run only from app orchestration after save" - ), + lambda *args, **kwargs: pytest.fail("status sync should run only from app orchestration after save"), ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + assert reviewer_bot.handle_comment_event(state) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" assert review["current_cycle_completion"]["completed"] is True assert posted == [(42, "✅ Added label `sign-off: create pr`")] + def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -67,25 +64,36 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) ) monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command")) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command"), + ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + assert reviewer_bot.handle_comment_event(state) is False assert review["review_completion_source"] is None + def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): called = {"post": 0} monkeypatch.setattr(reviewer_bot, "find_open_pr_for_branch_status", lambda branch: ("unavailable", None)) - monkeypatch.setattr(reviewer_bot, "github_api", lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None) + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None, + ) with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): reviewer_bot.create_pull_request("feature-branch", "main", 42) assert called["post"] == 0 + def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() - state["queue"] = [{"github": "felix91gr", "name": "F\u00e9lix Fischer"}] + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") @@ -93,34 +101,49 @@ def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_assign_command_posts_pr_guidance_on_success(monkeypatch): state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] monkeypatch.setenv("IS_PULL_REQUEST", "true") monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + ) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + assert success is True assert response == "✅ @felix91gr has been assigned as reviewer." assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + def test_claim_command_posts_pr_guidance_on_success(monkeypatch): state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] monkeypatch.setenv("IS_PULL_REQUEST", "true") monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + ) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_claim_command(state, 42, "felix91gr") + assert success is True assert response == "✅ @felix91gr has claimed this review." assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): state = make_state() state["queue"] = [ @@ -133,146 +156,42 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): monkeypatch.setenv("IS_PULL_REQUEST", "true") monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: ["alice"]) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + ) monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: True) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + assert success is True assert "@felix91gr is now assigned as the reviewer." in response assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + def test_assign_from_queue_posts_guidance_only_once(monkeypatch): state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] monkeypatch.setenv("IS_PULL_REQUEST", "true") monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201)) + monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + ) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + assert success is True assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] -def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): - commands_seen = [] - - def fake_run_command(command, cwd, check=True): - commands_seen.append(command) - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - raise AssertionError(f"Unexpected command: {command}") - - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] - assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] - -def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): - def fake_run_command(command, cwd, check=True): - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="README.md\nsrc/spec.lock\n", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="src/spec.lock\n", stderr="") - raise AssertionError(f"Unexpected command: {command}") - - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] - -def test_privileged_commands_workflow_executes_source_entrypoint(): - workflow_text = Path(".github/workflows/reviewer-bot-privileged-commands.yml").read_text(encoding="utf-8") - assert "Fetch trusted bot source tarball" in workflow_text - assert 'REVIEWER_BOT_TARGET_REPO_ROOT: ${{ github.workspace }}' in workflow_text - assert 'run: uv run --project "$BOT_SRC_ROOT" python "$BOT_SRC_ROOT/scripts/reviewer_bot.py"' in workflow_text - -def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - observed = {"cwd": None} - - def fake_list_changed_files(repo_root): - observed["cwd"] = repo_root - return ["README.md"] - - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) - assert observed["cwd"] == tmp_path - -def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - list_calls = {"count": 0} - - def fake_list_changed_files(repo_root): - list_calls["count"] += 1 - assert repo_root == tmp_path - return [] - - commands = [] - - def fake_run_command(command, cwd, check=False): - commands.append((command, cwd, check)) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) - assert list_calls["count"] == 2 - assert commands == [ - (["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], tmp_path, False), - (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), - ] - -def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) - - def fake_run_command(command, cwd, check=False): - return subprocess.CompletedProcess( - command, - 1, - stdout="", - stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", - ) - - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") - assert success is False - assert "Audit command failed." in message - assert "--locked was provided" in message - -def test_update_spec_lock_file_mode_exits_before_build_docs(monkeypatch, tmp_path): - monkeypatch.setattr(build_cli.argparse.ArgumentParser, "parse_args", lambda self: type("Args", (), { - "clear": False, - "offline": False, - "ignore_spec_lock_diff": False, - "update_spec_lock_file": True, - "validate_urls": False, - "serve": False, - "check_links": False, - "xml": False, - "verbose": False, - "debug": False, - })()) - called = {"update": 0, "build": 0} - monkeypatch.setattr(build_cli, "update_spec_lockfile", lambda url, path: called.__setitem__("update", called["update"] + 1) or True) - monkeypatch.setattr(build_cli, "build_docs", lambda *args, **kwargs: called.__setitem__("build", called["build"] + 1)) - with pytest.raises(SystemExit) as exc_info: - build_cli.main(tmp_path) - assert exc_info.value.code == 0 - assert called == {"update": 1, "build": 0} def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -284,6 +203,7 @@ def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unava assert success is False assert "Unable to verify triage permissions right now" in message + def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -295,6 +215,7 @@ def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] @@ -314,6 +235,7 @@ def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}] @@ -324,6 +246,7 @@ def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): state = make_state() monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") @@ -333,6 +256,7 @@ def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): assert success is False assert "Unable to verify triage permissions right now" in response + def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) @@ -342,6 +266,7 @@ def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}] @@ -352,6 +277,7 @@ def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monke assert success is False assert "Unable to determine current assignees/reviewers" in response + def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): state = make_state() monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) @@ -363,6 +289,7 @@ def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): assert changed is False assert "Unable to verify triage permissions right now" in message + def test_handle_rectify_command_reports_permission_denied(monkeypatch): state = make_state() monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) @@ -374,6 +301,7 @@ def test_handle_rectify_command_reports_permission_denied(monkeypatch): assert changed is False assert "Only maintainers with triage+ permission" in message + def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): monkeypatch.setenv("IS_PULL_REQUEST", "false") monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) @@ -388,6 +316,7 @@ def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavail assert ok is False assert metadata["reason"] == "authorization_unavailable" + def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) From 54d75117848dd31e72258cd86e32411d6b242a78 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:35:43 +0900 Subject: [PATCH 056/209] test(reviewer-bot): retire legacy project board file --- .../unit/reviewer_bot}/test_project_board.py | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) rename {.github/reviewer-bot-tests => tests/unit/reviewer_bot}/test_project_board.py (84%) diff --git a/.github/reviewer-bot-tests/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py similarity index 84% rename from .github/reviewer-bot-tests/test_project_board.py rename to tests/unit/reviewer_bot/test_project_board.py index a378c824b..579cb9698 100644 --- a/.github/reviewer-bot-tests/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -1,9 +1,5 @@ -from pathlib import Path - -import yaml -from factories import make_state, valid_reviewer_board_metadata - from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata def test_reviewer_board_preflight_validates_manifest(monkeypatch): @@ -17,6 +13,7 @@ def test_reviewer_board_preflight_validates_manifest(monkeypatch): assert preflight.valid is True assert preflight.project_id == "PVT_kwDOB" + def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -38,6 +35,7 @@ def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypat assert preview.desired.review_state == "Awaiting Reviewer" assert preview.desired.reviewer == "alice" + def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypatch): state = make_state() reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -56,6 +54,7 @@ def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypa assert preview.desired.waiting_since is None assert preview.desired.needs_attention == "No" + def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -75,6 +74,7 @@ def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch assert preview.desired.archive is True assert preview.desired.ensure_membership is False + def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypatch): state = make_state() monkeypatch.setattr( @@ -90,6 +90,7 @@ def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypa assert preview.desired is not None assert preview.desired.archive is True + def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -135,6 +136,7 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): assert preview.desired.assigned_at == "2026-03-20" assert preview.desired.waiting_since == "2026-03-21" + def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -189,6 +191,7 @@ def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state( assert preview.desired is not None assert preview.desired.review_state == reviewer_bot.REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR + def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -209,32 +212,8 @@ def test_preview_board_projection_marks_projection_repair_as_attention(monkeypat preview = reviewer_bot.preview_board_projection_for_item(state, 42) assert preview.desired is not None - assert ( - preview.desired.needs_attention - == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED - ) + assert preview.desired.needs_attention == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED -def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): - data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) - on_block = data.get("on", data.get(True)) - workflow_dispatch = on_block["workflow_dispatch"] - action_input = workflow_dispatch["inputs"]["action"] - assert "preview-reviewer-board" in action_input["options"] - issue_number_input = workflow_dispatch["inputs"]["issue_number"] - assert issue_number_input["required"] is False - assert issue_number_input["type"] == "string" - -def test_sweeper_repair_workflow_scopes_reviewer_board_env_to_preview_only(): - workflow_text = Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8") - assert "ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}" in workflow_text - assert ( - "REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }}" - in workflow_text - ) - assert ( - "REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }}" - in workflow_text - ) def test_reviewer_board_manifest_includes_projection_repair_attention_option(): options = reviewer_bot.REVIEWER_BOARD_PROJECT_MANIFEST[ From 0f0c6f5cdcb5a767d58c118931f9e2548cd0079e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:38:41 +0900 Subject: [PATCH 057/209] test(reviewer-bot): retire legacy lifecycle file --- .../test_lifecycle_integration.py | 72 +++++++++++++++ .../unit/reviewer_bot}/test_lifecycle.py | 88 +++++-------------- 2 files changed, 93 insertions(+), 67 deletions(-) create mode 100644 tests/integration/reviewer_bot/test_lifecycle_integration.py rename {.github/reviewer-bot-tests => tests/unit/reviewer_bot}/test_lifecycle.py (84%) diff --git a/tests/integration/reviewer_bot/test_lifecycle_integration.py b/tests/integration/reviewer_bot/test_lifecycle_integration.py new file mode 100644 index 000000000..b6a261147 --- /dev/null +++ b/tests/integration/reviewer_bot/test_lifecycle_integration.py @@ -0,0 +1,72 @@ +import json + +from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state + + +def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review["transition_warning_sent"] = "2026-03-18T00:00:00Z" + review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" + payload_path = tmp_path / "deferred-review.json" + payload_path.write_text( + json.dumps( + { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": 500, + "source_run_attempt": 2, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": "pull_request_review:11", + "pr_number": 42, + "review_id": 11, + "source_submitted_at": "2026-03-17T10:00:00Z", + "source_review_state": "COMMENTED", + "source_commit_id": "head-1", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") + monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + monkeypatch.setattr( + reviewer_bot, + "github_api", + lambda method, endpoint, data=None: { + "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, + "pulls/42/reviews/11": { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + }, + }.get(endpoint), + ) + monkeypatch.setattr( + reviewer_bot, + "get_pull_request_reviews", + lambda issue_number: [ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + assert reviewer_bot.handle_workflow_run_event(state) is True + assert review["transition_warning_sent"] is None + assert review["transition_notice_sent_at"] is None diff --git a/.github/reviewer-bot-tests/test_lifecycle.py b/tests/unit/reviewer_bot/test_lifecycle.py similarity index 84% rename from .github/reviewer-bot-tests/test_lifecycle.py rename to tests/unit/reviewer_bot/test_lifecycle.py index 963657bab..9fe9295e7 100644 --- a/.github/reviewer-bot-tests/test_lifecycle.py +++ b/tests/unit/reviewer_bot/test_lifecycle.py @@ -1,9 +1,9 @@ import json import pytest -from factories import make_state from scripts import reviewer_bot +from tests.fixtures.reviewer_bot import make_state def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutation(monkeypatch): @@ -16,7 +16,6 @@ def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutat monkeypatch.setenv("ISSUE_NUMBER", "42") monkeypatch.setenv("PR_HEAD_SHA", "head-2") monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setattr( reviewer_bot.reviews_module, "rebuild_pr_approval_state", @@ -26,6 +25,7 @@ def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutat assert reviewer_bot.handle_pull_request_target_synchronize(state) is True assert review["active_head_sha"] == "head-2" + def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): state = make_state(epoch="legacy_v14") entry = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -51,8 +51,10 @@ def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): "user": {"login": "dana"}, }, ) + assert reviewer_bot.handle_comment_event(state) is False + def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -68,8 +70,10 @@ def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(mon lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, ) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + def test_handle_transition_notice_records_transition_notice_sent_at_once(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -77,20 +81,24 @@ def test_handle_transition_notice_records_transition_notice_sent_at_once(monkeyp review["current_reviewer"] = "alice" posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True assert review["transition_notice_sent_at"] is not None assert reviewer_bot.handle_transition_notice(state, 42, "alice") is False assert len(posted) == 1 + def test_handle_transition_notice_message_does_not_claim_reassignment(monkeypatch): state = make_state() reviewer_bot.ensure_review_entry(state, 42, create=True) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True assert "reassigned to the next person in the queue" not in posted[0] assert "/pass" in posted[0] + def test_reviewer_comment_clears_warning_and_transition_notice_markers(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -118,10 +126,12 @@ def test_reviewer_comment_clears_warning_and_transition_notice_markers(monkeypat "user": {"login": "dana"}, }, ) + assert reviewer_bot.handle_comment_event(state) is True assert review["transition_warning_sent"] is None assert review["transition_notice_sent_at"] is None + def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -176,10 +186,12 @@ def fake_api(method, endpoint, data=None): raise AssertionError(endpoint) monkeypatch.setattr(reviewer_bot, "github_api", fake_api) + assert reviewer_bot.handle_scheduled_check(state) is True assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" assert posted == [] + def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation(monkeypatch): review_data = { "active_head_sha": "head-1", @@ -210,6 +222,7 @@ def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation ) assert review_data["active_head_sha"] == "head-1" + def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} monkeypatch.setattr( @@ -232,6 +245,7 @@ def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): assert result.outcome == "skipped_not_found" assert result.failure_kind == "not_found" + def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatch): review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} monkeypatch.setattr( @@ -254,6 +268,7 @@ def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatc assert result.outcome == "invalid_live_payload" assert result.reason == "pull_request_head_unavailable" + def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} monkeypatch.setattr( @@ -275,6 +290,7 @@ def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): assert result.outcome == "skipped_not_open" + def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): review_data = { "active_head_sha": "head-1", @@ -317,6 +333,7 @@ def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypa assert review_data["current_cycle_write_approval"] == {} assert review_data["review_completed_at"] is None + def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() monkeypatch.setenv("ISSUE_NUMBER", "42") @@ -326,6 +343,7 @@ def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monke with pytest.raises(RuntimeError, match="Unable to determine assignees"): reviewer_bot.handle_issue_or_pr_opened(state) + def test_issue_edit_by_author_records_contributor_freshness(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -340,75 +358,11 @@ def test_issue_edit_by_author_records_contributor_freshness(monkeypatch): monkeypatch.setenv("ISSUE_CHANGES_TITLE_FROM", "Old title") monkeypatch.setenv("ISSUE_CHANGES_BODY_FROM", "body") monkeypatch.setenv("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") + assert reviewer_bot.handle_issue_edited_event(state) is True accepted = review["contributor_comment"]["accepted"] assert accepted["semantic_key"].startswith("issues_edit_title:42:") -def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None def test_maybe_record_head_observation_repair_uses_github_api_fallback_after_system_exit(monkeypatch): review_data = { From a618041fb3c10c8b605ef57e17fdd2308f887cbc Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:40:20 +0900 Subject: [PATCH 058/209] ci(reviewer-bot): make migrated tests authoritative --- .github/workflows/reviewer-bot-tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index d00261145..f3dc954b0 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -34,17 +34,17 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Run reviewer bot tests with targeted coverage + - name: Run migrated reviewer bot tests with targeted coverage run: >- - uv run pytest .github/reviewer-bot-tests + uv run pytest tests --import-mode=importlib --cov=scripts.reviewer_bot --cov=scripts.reviewer_bot_lib --cov-branch --cov-report=term-missing --cov-report=json:coverage-reviewer-bot.json - - name: Run migrated reviewer bot tests with importlib collection - run: uv run pytest tests --import-mode=importlib + - name: Run remaining legacy reviewer bot tests + run: uv run pytest .github/reviewer-bot-tests - name: Enforce reviewer bot statement and branch floors run: | From 27862706b69ebe47b8abf82e6e992baf3d0e477f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 02:47:46 +0900 Subject: [PATCH 059/209] test(reviewer-bot): retire legacy reviewer bot tree --- .github/reviewer-bot-tests/conftest.py | 141 --- .github/reviewer-bot-tests/factories.py | 97 -- .github/reviewer-bot-tests/fakes.py | 12 - .github/reviewer-bot-tests/test_github_api.py | 400 -------- .github/reviewer-bot-tests/test_lease_lock.py | 320 ------- .../reviewer-bot-tests/test_maintenance.py | 154 --- .github/reviewer-bot-tests/test_overdue.py | 249 ----- .../test_reviewer_bot_adapter.py | 38 - .github/reviewer-bot-tests/test_reviews.py | 877 ------------------ .../reviewer-bot-tests/test_state_store.py | 104 --- .github/workflows/reviewer-bot-tests.yml | 5 - .../reviewer_bot/test_cli_entrypoint.py | 6 +- 12 files changed, 4 insertions(+), 2399 deletions(-) delete mode 100644 .github/reviewer-bot-tests/conftest.py delete mode 100644 .github/reviewer-bot-tests/factories.py delete mode 100644 .github/reviewer-bot-tests/fakes.py delete mode 100644 .github/reviewer-bot-tests/test_github_api.py delete mode 100644 .github/reviewer-bot-tests/test_lease_lock.py delete mode 100644 .github/reviewer-bot-tests/test_maintenance.py delete mode 100644 .github/reviewer-bot-tests/test_overdue.py delete mode 100644 .github/reviewer-bot-tests/test_reviewer_bot_adapter.py delete mode 100644 .github/reviewer-bot-tests/test_reviews.py delete mode 100644 .github/reviewer-bot-tests/test_state_store.py rename .github/reviewer-bot-tests/test_app.py => tests/integration/reviewer_bot/test_cli_entrypoint.py (99%) diff --git a/.github/reviewer-bot-tests/conftest.py b/.github/reviewer-bot-tests/conftest.py deleted file mode 100644 index 2757cc93d..000000000 --- a/.github/reviewer-bot-tests/conftest.py +++ /dev/null @@ -1,141 +0,0 @@ -import pytest - -from scripts import reviewer_bot - - -@pytest.fixture(autouse=True) -def clear_reviewer_bot_env(): - env_vars = { - "COMMENT_BODY", - "COMMENT_AUTHOR", - "COMMENT_ID", - "COMMENT_SOURCE_EVENT_KEY", - "ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", - "EVENT_ACTION", - "EVENT_NAME", - "ISSUE_NUMBER", - "ISSUE_AUTHOR", - "IS_PULL_REQUEST", - "ISSUE_LABELS", - "LABEL_NAME", - "MANUAL_ACTION", - "PR_IS_CROSS_REPOSITORY", - "REVIEW_AUTHOR", - "REVIEW_STATE", - "REPO_OWNER", - "REPO_NAME", - "WORKFLOW_RUN_EVENT", - "WORKFLOW_RUN_EVENT_ACTION", - "WORKFLOW_RUN_HEAD_SHA", - "WORKFLOW_RUN_RECONCILE_PR_NUMBER", - "WORKFLOW_RUN_RECONCILE_HEAD_SHA", - "WORKFLOW_RUN_ID", - "WORKFLOW_NAME", - "WORKFLOW_JOB_NAME", - } - with pytest.MonkeyPatch().context() as monkeypatch: - for name in env_vars: - monkeypatch.delenv(name, raising=False) - monkeypatch.setattr( - reviewer_bot, - "ACTIVE_LEASE_CONTEXT", - reviewer_bot.LeaseContext( - lock_token="test-lock-token", - lock_owner_run_id="test-run", - lock_owner_workflow="test-workflow", - lock_owner_job="test-job", - state_issue_url="https://example.com/state", - ), - ) - monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - yield - - -@pytest.fixture -def setenv_many(monkeypatch): - def setter(values: dict[str, str]): - for key, value in values.items(): - monkeypatch.setenv(key, value) - - return setter - - -@pytest.fixture -def lease_context(): - return reviewer_bot.LeaseContext( - lock_token="test-lock-token", - lock_owner_run_id="test-run", - lock_owner_workflow="test-workflow", - lock_owner_job="test-job", - state_issue_url="https://example.com/state", - ) - - -@pytest.fixture -def tmp_deferred_path(tmp_path): - return tmp_path / "deferred-context.json" - - -@pytest.fixture -def stub_api(monkeypatch): - monkeypatch.setattr(reviewer_bot, "github_api", lambda *args, **kwargs: {}) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={}, - headers={}, - text="", - ok=True, - ), - ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "assign_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda *args, **kwargs: reviewer_bot.AssignmentAttempt(success=True, status_code=201), - ) - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_pr_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_assignee", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda *args, **kwargs: {"a", "b"}) - monkeypatch.setattr(reviewer_bot, "add_label", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "add_label_with_status", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_label", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_label_with_status", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "fetch_members", lambda *args, **kwargs: []) - - -@pytest.fixture -def captured_comments(monkeypatch): - comments = [] - - def record_comment(issue_number, body): - comments.append({"issue_number": issue_number, "body": body}) - return True - - monkeypatch.setattr(reviewer_bot, "post_comment", record_comment) - return comments - - -@pytest.fixture -def captured_status_label_ops(monkeypatch): - operations = [] - - def record_add(issue_number, label): - operations.append(("add", issue_number, label)) - return True - - def record_remove(issue_number, label): - operations.append(("remove", issue_number, label)) - return True - - monkeypatch.setattr(reviewer_bot, "add_label_with_status", record_add) - monkeypatch.setattr(reviewer_bot, "remove_label_with_status", record_remove) - monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) - return operations diff --git a/.github/reviewer-bot-tests/factories.py b/.github/reviewer-bot-tests/factories.py deleted file mode 100644 index 616f15e83..000000000 --- a/.github/reviewer-bot-tests/factories.py +++ /dev/null @@ -1,97 +0,0 @@ -import io -import json -import zipfile - -from scripts import reviewer_bot - - -def make_state(epoch: str | None = None): - state = { - "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, - "freshness_runtime_epoch": epoch or reviewer_bot.FRESHNESS_RUNTIME_EPOCH_V18, - "status_projection_epoch": reviewer_bot.STATUS_PROJECTION_EPOCH, - "last_updated": None, - "current_index": 0, - "queue": [ - {"github": "alice", "name": "Alice"}, - {"github": "bob", "name": "Bob"}, - {"github": "carol", "name": "Carol"}, - ], - "pass_until": [], - "recent_assignments": [], - "active_reviews": {}, - } - if epoch == "freshness_v15": - state["freshness_runtime_epoch"] = epoch - state.pop("status_projection_epoch", None) - return state - - -def valid_reviewer_board_metadata(): - return { - "data": { - "organization": { - "projectV2": { - "id": "PVT_kwDOB", - "title": "Reviewer Board", - "fields": { - "nodes": [ - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-review-state", - "name": "Review State", - "options": [ - {"id": "opt-ar", "name": "Awaiting Reviewer"}, - {"id": "opt-ac", "name": "Awaiting Contributor"}, - {"id": "opt-aw", "name": "Awaiting Write Approval"}, - {"id": "opt-done", "name": "Done"}, - {"id": "opt-unassigned", "name": "Unassigned"}, - ], - }, - { - "__typename": "ProjectV2Field", - "dataType": "TEXT", - "id": "field-reviewer", - "name": "Reviewer", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-assigned-at", - "name": "Assigned At", - }, - { - "__typename": "ProjectV2Field", - "dataType": "DATE", - "id": "field-waiting-since", - "name": "Waiting Since", - }, - { - "__typename": "ProjectV2SingleSelectField", - "id": "field-needs-attention", - "name": "Needs Attention", - "options": [ - {"id": "opt-no", "name": "No"}, - {"id": "opt-warning", "name": "Warning Sent"}, - {"id": "opt-notice", "name": "Transition Notice Sent"}, - {"id": "opt-triage", "name": "Triage Approval Required"}, - {"id": "opt-repair", "name": "Projection Repair Required"}, - ], - }, - ] - }, - } - } - } - } - - -def iso_z(dt): - return dt.isoformat().replace("+00:00", "Z") - - -def make_zip_payload(file_name: str, payload: dict) -> bytes: - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - archive.writestr(file_name, json.dumps(payload)) - return buffer.getvalue() diff --git a/.github/reviewer-bot-tests/fakes.py b/.github/reviewer-bot-tests/fakes.py deleted file mode 100644 index 26e17e48c..000000000 --- a/.github/reviewer-bot-tests/fakes.py +++ /dev/null @@ -1,12 +0,0 @@ -class FakeGitHubResponse: - def __init__(self, status_code, payload=None, text="", headers=None): - self.status_code = status_code - self._payload = payload - self.text = text - self.headers = headers or {} - self.content = b"" if payload is None and not text else b"x" - - def json(self): - if isinstance(self._payload, Exception): - raise self._payload - return self._payload diff --git a/.github/reviewer-bot-tests/test_github_api.py b/.github/reviewer-bot-tests/test_github_api.py deleted file mode 100644 index 3a3b7f0fd..000000000 --- a/.github/reviewer-bot-tests/test_github_api.py +++ /dev/null @@ -1,400 +0,0 @@ -import pytest -from fakes import FakeGitHubResponse - -from scripts import reviewer_bot - - -def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( - [ - FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"ok": True} - assert result.retry_attempts == 1 - assert result.failure_kind is None - -def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - if calls["count"] == 1: - raise reviewer_bot.github_api_module.requests.RequestException("timeout") - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"ok": True} - assert result.retry_attempts == 1 - -def test_github_api_request_classifies_not_found_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(404, {"message": "missing"}, "missing") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) - - assert result.ok is False - assert result.failure_kind == "not_found" - assert result.retry_attempts == 0 - assert calls["count"] == 1 - -def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) - - assert result.ok is False - assert result.failure_kind == "forbidden" - assert result.retry_attempts == 0 - assert calls["count"] == 1 - -def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - responses = iter( - [ - FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") - - assert result.ok is True - assert result.payload == {"data": {"viewer": {"login": "bot"}}} - assert result.retry_attempts == 1 - -def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( - [ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] - ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") - - assert result.ok is True - assert result.retry_attempts == 1 - -def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "rate_limited" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - -def test_github_graphql_request_reports_invalid_payload(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - -def test_github_graphql_request_reports_graphql_errors(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse( - 200, {"errors": [{"message": "boom"}]}, "boom" - ), - ) - - result = reviewer_bot.github_graphql_request("query { viewer { login } }") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - -def test_github_api_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - observed = {} - - def fake_request(method, url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - - result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) - - assert result.ok is True - assert observed["timeout"] == 12.5 - -def test_github_api_request_rejects_idempotent_retry_for_non_get(): - with pytest.raises(ValueError, match="only valid for REST GET"): - reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") - -def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): - with pytest.raises(ValueError, match="only valid for GraphQL queries"): - reviewer_bot.github_graphql_request( - "mutation { closeIssue(input: {}) { clientMutationId } }", - retry_policy="idempotent_read", - ) - -def test_remove_label_reports_failure(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=500, - payload={"message": "boom"}, - headers={}, - text="boom", - ok=False, - failure_kind="server_error", - retry_attempts=0, - transport_error=None, - ), - ) - - assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False - -def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" - assert reviewer_bot.check_user_permission("alice", "triage") is None - -def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.get_issue_assignees(42) is None - -def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"not": "a list"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) - -def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) - -def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) - - result = reviewer_bot.github_api_request("GET", "issues/42") - - assert result.ok is False - assert result.failure_kind == "invalid_payload" - -def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "server_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - -def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - - def always_fail(*args, **kwargs): - raise reviewer_bot.github_api_module.requests.RequestException("timeout") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) - - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) - - assert result.ok is False - assert result.failure_kind == "transport_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT - assert "timeout" in str(result.transport_error) - -def test_github_graphql_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - observed = {} - - def fake_post(url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") - - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) - - result = reviewer_bot.github_graphql_request( - "query { viewer { login } }", timeout_seconds=9.5 - ) - - assert result.ok is True - assert observed["timeout"] == 9.5 - -def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): - monkeypatch.delenv("REPO_OWNER", raising=False) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) - -def test_find_open_pr_for_branch_status_reports_found(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - [{"number": 42, "html_url": "https://example.com/pr/42"}], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") - - assert status == "found" - assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} - -def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, [], {}, "ok", True, None, 0, None - ), - ) - - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) diff --git a/.github/reviewer-bot-tests/test_lease_lock.py b/.github/reviewer-bot-tests/test_lease_lock.py deleted file mode 100644 index b9e07a4a4..000000000 --- a/.github/reviewer-bot-tests/test_lease_lock.py +++ /dev/null @@ -1,320 +0,0 @@ -import pytest - -from scripts import reviewer_bot - - -def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref-2", "tree", {"lock_state": "unlocked", "lock_token": None}), - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "local-run", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - context = reviewer_bot.acquire_state_issue_lease_lock() - - assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context - -def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - with pytest.raises(RuntimeError, match="unexpected lock state"): - reviewer_bot.acquire_state_issue_lease_lock() - -def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "local-run", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ), - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-1"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - context = reviewer_bot.acquire_state_issue_lease_lock() - - assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context - -def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): - monkeypatch.setattr( - reviewer_bot.lease_lock_module, - "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), - ) - snapshots = iter( - [ - ( - "new-ref", - "tree", - { - "lock_state": "locked", - "lock_token": "token-123", - "lock_owner_run_id": "someone-else", - "lock_owner_workflow": "reviewer-bot", - "lock_owner_job": "reviewer-bot", - "lock_expires_at": "2999-01-01T00:00:00+00:00", - }, - ) - ] - ) - - monkeypatch.setattr(reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) - - with pytest.raises(RuntimeError, match="owner metadata drifted"): - reviewer_bot.acquire_state_issue_lease_lock() - -def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="token-123", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - snapshots = iter( - [ - ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), - ("new-ref", "tree", {"lock_state": "locked", "lock_token": "token-123"}), - ] - ) - - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "create_lock_commit", lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult(201, {"sha": "commit-2"}, {}, "", True)) - monkeypatch.setattr(reviewer_bot, "cas_update_lock_ref", lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True)) - - assert reviewer_bot.release_state_issue_lease_lock() is True - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None - -def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="token-123", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"})) - - assert reviewer_bot.release_state_issue_lease_lock() is False - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None - -def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): - observed = [] - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed.append((endpoint, kwargs.get("retry_policy"))) - if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: - return reviewer_bot.GitHubApiResult( - status_code=404, - payload={"message": "missing"}, - headers={}, - text="missing", - ok=False, - failure_kind="not_found", - retry_attempts=0, - transport_error=None, - ) - if endpoint == "git/ref/heads/reviewer-bot-state-lock": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"object": {"sha": "base-sha"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "git/ref/heads/main": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"object": {"sha": "base-sha"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ) - if endpoint == "git/refs": - return reviewer_bot.GitHubApiResult( - status_code=201, - payload={"ref": "refs/heads/reviewer-bot-state-lock"}, - headers={}, - text="created", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") - - assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" - assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") - assert observed[1] == ("git/ref/heads/main", "idempotent_read") - -def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): - responses = iter( - [ - reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), - reviewer_bot.GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") - - with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): - reviewer_bot.ensure_lock_ref_exists() - -def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"message": "missing tree"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="missing tree SHA"): - reviewer_bot.get_lock_ref_snapshot() - -def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): - context = reviewer_bot.LeaseContext( - lock_token="expected-token", - lock_owner_run_id="run-1", - lock_owner_workflow="wf", - lock_owner_job="job", - state_issue_url="https://example.com/state/1", - ) - monkeypatch.setattr( - reviewer_bot, - "get_lock_ref_snapshot", - lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), - ) - - assert reviewer_bot.renew_state_issue_lease_lock(context) is False - -def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), - ) - - with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): - reviewer_bot.ensure_lock_ref_exists() - -def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): - responses = iter( - [ - reviewer_bot.GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), - reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) - - with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): - reviewer_bot.ensure_lock_ref_exists() - -def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), - ) - - with pytest.raises(RuntimeError, match="Failed to read lock commit"): - reviewer_bot.get_lock_ref_snapshot() diff --git a/.github/reviewer-bot-tests/test_maintenance.py b/.github/reviewer-bot-tests/test_maintenance.py deleted file mode 100644 index 18b6b35b3..000000000 --- a/.github/reviewer-bot-tests/test_maintenance.py +++ /dev/null @@ -1,154 +0,0 @@ - -from factories import make_state - -from scripts import reviewer_bot - - -def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - assert reviewer_bot.handle_scheduled_check(state) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted is not None - assert accepted["semantic_key"] == "pull_request_review:10" - assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" - -def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): - state = make_state() - review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) - review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) - assert review_42 is not None - assert review_43 is not None - review_42["current_reviewer"] = "alice" - review_43["current_reviewer"] = "bob" - overdue_called = [] - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: overdue_called.append(True) or []) - monkeypatch.setattr(reviewer_bot.reviews_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) - - def fake_head_repair(bot, issue_number, review_data): - if issue_number == 42: - return reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="skipped_unavailable", - failure_kind="server_error", - reason="pull_request_unavailable", - ) - review_data["repair_needed"] = { - "kind": "live_read_failure", - "phase": "head_observation_repair", - "reason": "stale", - "failure_kind": "server_error", - "recorded_at": "2026-03-01T00:00:00Z", - } - return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") - - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) - - assert reviewer_bot.handle_scheduled_check(state) is True - assert overdue_called == [True] - assert review_42["repair_needed"]["kind"] == "live_read_failure" - assert review_42["repair_needed"]["phase"] == "head_observation_repair" - assert review_42["repair_needed"]["failure_kind"] == "server_error" - assert review_43["repair_needed"] is None - -def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) - monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) - - first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, - review, - phase="head_observation_repair", - reason="pull_request_unavailable", - failure_kind="server_error", - ) - second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, - review, - phase="head_observation_repair", - reason="pull_request_unavailable", - failure_kind="server_error", - ) - - assert first is True - assert second is False - assert review["repair_needed"]["recorded_at"] == "2026-03-01T00:00:00Z" - -def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["repair_needed"] = { - "kind": "live_read_failure", - "phase": "head_observation_repair", - "reason": "pull_request_unavailable", - "failure_kind": "server_error", - "recorded_at": "2026-03-01T00:00:00Z", - } - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) - - assert reviewer_bot.handle_scheduled_check(state) is True - assert review["repair_needed"] is None diff --git a/.github/reviewer-bot-tests/test_overdue.py b/.github/reviewer-bot-tests/test_overdue.py deleted file mode 100644 index 1473b30b8..000000000 --- a/.github/reviewer-bot-tests/test_overdue.py +++ /dev/null @@ -1,249 +0,0 @@ -from datetime import timedelta - -from factories import iso_z, make_state - -from scripts import reviewer_bot - - -def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - -def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) - - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - -def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) - - assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False - assert review["transition_warning_sent"] is None - -def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_comment_at = iso_z( - now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) - ) - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_comment"]["accepted"] = { - "semantic_key": "issue_comment:20", - "timestamp": contributor_comment_at, - "actor": "bob", - "reviewed_head_sha": None, - "source_precedence": 0, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) - assert overdue[0]["issue_number"] == 42 - assert overdue[0]["needs_warning"] is True - assert overdue[0]["days_overdue"] == 0 - -def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_changes_after_review(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_revision_at = iso_z( - now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1) - ) - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_sync:42:head-2", - "timestamp": contributor_revision_at, - "actor": None, - "reviewed_head_sha": "head-2", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) - assert overdue[0]["issue_number"] == 42 - assert overdue[0]["needs_warning"] is True - assert overdue[0]["days_overdue"] == 0 - -def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_head_observed:42:head-1", - "timestamp": "2026-03-12T00:00:00Z", - "actor": None, - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] - -def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-02T00:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-20T00:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-21T00:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] diff --git a/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py b/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py deleted file mode 100644 index 9d9e5fd0d..000000000 --- a/.github/reviewer-bot-tests/test_reviewer_bot_adapter.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import get_type_hints - -from scripts import reviewer_bot -from scripts.reviewer_bot_lib.context import ( - GitHubTransportContext, - LeaseLockContext, - ReviewerBotContext, - StateStoreContext, -) - - -def test_reviewer_bot_exports_runtime_modules(): - assert reviewer_bot.requests is not None - assert reviewer_bot.sys is not None - assert reviewer_bot.datetime is not None - assert reviewer_bot.timezone is not None - -def test_reviewer_bot_satisfies_runtime_context_protocol(): - assert isinstance(reviewer_bot, ReviewerBotContext) - -def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): - assert isinstance(reviewer_bot, GitHubTransportContext) - assert isinstance(reviewer_bot, StateStoreContext) - assert isinstance(reviewer_bot, LeaseLockContext) - -def test_render_lock_commit_message_uses_direct_json_import(): - rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) - assert reviewer_bot.LOCK_COMMIT_MARKER in rendered - -def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): - hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) - - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult - -def test_runtime_context_protocol_exposes_structured_head_repair_contract(): - hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) - - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult diff --git a/.github/reviewer-bot-tests/test_reviews.py b/.github/reviewer-bot-tests/test_reviews.py deleted file mode 100644 index e21031587..000000000 --- a/.github/reviewer-bot-tests/test_reviews.py +++ /dev/null @@ -1,877 +0,0 @@ -import json - -import pytest -from factories import make_state - -from scripts import reviewer_bot - - -def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "review_head_stale" - -def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - -def test_project_status_labels_uses_live_review_fallback_for_stale_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "review_head_stale" - -def test_project_status_labels_prefers_current_head_review_over_newer_stale_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 11, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:01:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - -def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_contributor_response" - assert response_state["reason"] == "completion_missing" - assert response_state["reviewer_review"]["semantic_key"] == "pull_request_review:10" - assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" - -def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - -def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_revision", - semantic_key="pull_request_sync:42:head-1", - timestamp="2026-03-17T12:00:00Z", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_reviewer_response" - assert response_state["reason"] == "contributor_revision_newer" - -def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "vccjgust" - review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-02-18T09:30:00Z", - actor="dana", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 30, - "state": "COMMENTED", - "submitted_at": "2026-02-18T10:00:00Z", - "commit_id": "head-older", - "user": {"login": "vccjgust"}, - }, - { - "id": 31, - "state": "COMMENTED", - "submitted_at": "2026-02-18T11:00:00Z", - "commit_id": "head-current", - "user": {"login": "vccjgust"}, - }, - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - -def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "contributor_comment", - semantic_key="issue_comment:20", - timestamp="2026-03-17T10:05:00Z", - actor="bob", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - ) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "contributor_comment_newer" - -def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["last_reviewer_activity"] = "2026-03-20T10:00:00Z" - review["transition_warning_sent"] = "2026-03-21T10:00:00Z" - review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z" - reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z") - assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z" - assert review["transition_warning_sent"] == "2026-03-21T10:00:00Z" - assert review["transition_notice_sent_at"] == "2026-03-22T10:00:00Z" - -def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_comment", - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "bob"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} - assert metadata["state"] == "awaiting_write_approval" - review["mandatory_approver_required"] = True - desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} - -def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "pull_request_unavailable" - -def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid( - monkeypatch, -): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {}}, - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "pull_request_head_unavailable" - -def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "permission_unavailable" - -def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) - - with pytest.raises(RuntimeError, match="server_error"): - reviewer_bot.list_open_items_with_status_labels() - -def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( - review, - "reviewer_review", - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], - ) - assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True - accepted = review["reviewer_review"]["accepted"] - assert accepted["semantic_key"] == "pull_request_review:10" - assert accepted["reviewed_head_sha"] == "head-1" - assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] - -def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change( - monkeypatch, -): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["reviewer_review"] = { - "accepted": { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-17T10:01:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - }, - "seen_keys": ["pull_request_review:10"], - } - review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( - reviewer_bot, - 42, - review, - ) - - assert changed is True - assert preferred_review is not None - assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z" - assert review["transition_warning_sent"] is None - assert review["transition_notice_sent_at"] is None - -def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - response_state = reviewer_bot.compute_reviewer_response_state(42, review) - - assert response_state["state"] == "awaiting_reviewer_response" - assert review == before - -def test_compute_pr_approval_state_result_is_pure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - - result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) - - assert result["ok"] is True - assert result["completion"]["completed"] is True - assert review == before - -def test_apply_pr_approval_state_mutates_expected_fields(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - - reviewer_bot.reviews_module.apply_pr_approval_state( - review, - completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, - write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, - current_head_sha="head-1", - ) - - assert review["active_head_sha"] == "head-1" - assert review["current_cycle_completion"]["completed"] is True - assert review["current_cycle_write_approval"]["has_write_approval"] is True - assert review["review_completion_source"] == "live_review_rebuild" - -def test_get_pull_request_reviews_result_paginates(monkeypatch): - responses = { - "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult(200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None), - "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult(200, [{"id": 100}], {}, "ok", True, None, 0, None), - } - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], - ) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result["ok"] is True - assert len(result["reviews"]) == 101 - -def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result == {"ok": True, "reviews": [{"id": 10}]} - -def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, {"not": "a list"}, {}, "ok", True, None, 0, None - ), - ) - - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} - -def test_pull_request_read_result_reports_not_found(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None - ), - ) - - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} - -def test_pull_request_read_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None - ), - ) - - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) - - assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} diff --git a/.github/reviewer-bot-tests/test_state_store.py b/.github/reviewer-bot-tests/test_state_store.py deleted file mode 100644 index 8df2c5fc9..000000000 --- a/.github/reviewer-bot-tests/test_state_store.py +++ /dev/null @@ -1,104 +0,0 @@ -from factories import make_state - -from scripts import reviewer_bot - - -def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): - monkeypatch.setattr(reviewer_bot, "get_state_issue", lambda: {"body": "queue: []\n"}) - state = reviewer_bot.load_state() - assert state["schema_version"] == reviewer_bot.STATE_SCHEMA_VERSION - assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY - -def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["retry_policy"] = kwargs.get("retry_policy") - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, - headers={"etag": '"abc"'}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - snapshot = reviewer_bot.get_state_issue_snapshot() - - assert snapshot is not None - assert snapshot.etag == '"abc"' - assert observed["retry_policy"] == "idempotent_read" - -def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"body": data["body"]}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') - - assert observed["extra_headers"] == {"If-Match": '"etag-1"'} - -def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): - observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - - def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): - observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - - reviewer_bot.conditional_patch_state_issue("updated", None) - - assert observed["extra_headers"] is None - -def test_save_state_retries_precondition_failed_conflict(monkeypatch): - state = make_state() - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - snapshot = reviewer_bot.StateIssueSnapshot( - body="body", - etag='"etag"', - html_url="https://example.com/state/1", - ) - responses = iter( - [ - reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), - reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), - ] - ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) - monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) - monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) - monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) - monkeypatch.setattr( - reviewer_bot, - "render_state_issue_body", - lambda state_obj, lock_meta, base_body: "updated", - ) - monkeypatch.setattr( - reviewer_bot, - "conditional_patch_state_issue", - lambda body, etag=None: next(responses), - ) - monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) - - assert reviewer_bot.save_state(state) is True diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index f3dc954b0..0e8d92fe6 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -8,7 +8,6 @@ on: - 'scripts/*.py' - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - - '.github/reviewer-bot-tests/**' - 'pyproject.toml' pull_request: paths: @@ -17,7 +16,6 @@ on: - 'scripts/*.py' - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - - '.github/reviewer-bot-tests/**' - 'pyproject.toml' workflow_dispatch: @@ -43,9 +41,6 @@ jobs: --cov-report=term-missing --cov-report=json:coverage-reviewer-bot.json - - name: Run remaining legacy reviewer bot tests - run: uv run pytest .github/reviewer-bot-tests - - name: Enforce reviewer bot statement and branch floors run: | uv run python - <<'PY' diff --git a/.github/reviewer-bot-tests/test_app.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py similarity index 99% rename from .github/reviewer-bot-tests/test_app.py rename to tests/integration/reviewer_bot/test_cli_entrypoint.py index 6685408a2..284571616 100644 --- a/.github/reviewer-bot-tests/test_app.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -1,11 +1,13 @@ import json import pytest -from factories import make_state -from factories import valid_reviewer_board_metadata as _valid_reviewer_board_metadata from scripts import reviewer_bot from scripts.reviewer_bot_lib.lifecycle import HeadObservationRepairResult +from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.reviewer_bot import ( + valid_reviewer_board_metadata as _valid_reviewer_board_metadata, +) def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): From ab60102c85b9d4325e485a7eef634959b78edc8a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 03:31:45 +0900 Subject: [PATCH 060/209] ci(reviewer-bot): store coverage artifacts under artifacts --- .github/workflows/reviewer-bot-tests.yml | 6 ++++-- .gitignore | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index 0e8d92fe6..1149296b9 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -34,19 +34,21 @@ jobs: - name: Run migrated reviewer bot tests with targeted coverage run: >- + mkdir -p artifacts/coverage && + COVERAGE_FILE=artifacts/coverage/.coverage uv run pytest tests --import-mode=importlib --cov=scripts.reviewer_bot --cov=scripts.reviewer_bot_lib --cov-branch --cov-report=term-missing - --cov-report=json:coverage-reviewer-bot.json + --cov-report=json:artifacts/coverage/reviewer-bot.json - name: Enforce reviewer bot statement and branch floors run: | uv run python - <<'PY' import json - with open("coverage-reviewer-bot.json", encoding="utf-8") as handle: + with open("artifacts/coverage/reviewer-bot.json", encoding="utf-8") as handle: totals = json.load(handle)["totals"] statements = totals["percent_statements_covered"] diff --git a/.gitignore b/.gitignore index 9328f2b33..f157798ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ build/ *.egg-info/ .cache/ .venv/ +.coverage +coverage*.json +coverage.xml +htmlcov/ +artifacts/coverage/ From 315ceeb7d8533eae34b7062c2fe25fb519625574 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 03:38:25 +0900 Subject: [PATCH 061/209] ci(reviewer-bot): finalize migrated test configuration --- .github/workflows/reviewer-bot-tests.yml | 21 ++++++++++++++++++--- pyproject.toml | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index 1149296b9..39496adea 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -20,7 +20,7 @@ on: workflow_dispatch: jobs: - reviewer-bot-tests: + reviewer-bot-unit: runs-on: ubuntu-latest steps: - name: Checkout repository @@ -32,11 +32,11 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Run migrated reviewer bot tests with targeted coverage + - name: Run reviewer bot unit and integration tests with targeted coverage run: >- mkdir -p artifacts/coverage && COVERAGE_FILE=artifacts/coverage/.coverage - uv run pytest tests --import-mode=importlib + uv run pytest tests/unit/reviewer_bot tests/integration/reviewer_bot --cov=scripts.reviewer_bot --cov=scripts.reviewer_bot_lib --cov-branch @@ -66,3 +66,18 @@ jobs: f"reviewer-bot branch coverage below floor: {branches:.2f}% < 48%" ) PY + + reviewer-bot-contract: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --dev + + - name: Run reviewer bot contract tests + run: uv run pytest tests/contract/reviewer_bot diff --git a/pyproject.toml b/pyproject.toml index 03cc22b67..c0a7317c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,30 @@ lint.ignore = [ [tool.ruff.lint.flake8-comprehensions] allow-dict-calls-with-keyword-arguments = true +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "--import-mode=importlib", + "-ra", + "--strict-config", + "--strict-markers", +] +markers = [ + "integration: filesystem, subprocess, multi-module, or boundary tests", + "contract: workflow, CLI, adapter, or artifact contract tests", +] +filterwarnings = ["error"] +xfail_strict = true + +[tool.coverage.run] +branch = true +relative_files = true +source = ["scripts.reviewer_bot", "scripts.reviewer_bot_lib"] + +[tool.coverage.report] +show_missing = true +skip_empty = true + [dependency-groups] dev = [ "pytest>=8.3.0", From 86086412527a5e00d7561cc6a37ef1987caaf6c7 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 03:49:19 +0900 Subject: [PATCH 062/209] ci(reviewer-bot): add marker-based test jobs --- .github/workflows/reviewer-bot-tests.yml | 62 ++++++++++++++----- .../reviewer_bot/test_adapter_contract.py | 12 +--- .../test_privileged_commands_workflow.py | 2 + .../test_reviewer_board_workflow_contracts.py | 5 +- .../test_workflow_artifact_contracts.py | 8 +-- .../reviewer_bot/test_workflow_files.py | 12 +--- .../test_accept_no_fls_changes.py | 9 +-- .../test_app_closed_issue_cleanup.py | 3 +- .../reviewer_bot/test_app_execution.py | 4 +- .../test_app_reviewer_board_preview.py | 5 +- .../test_app_schedule_bookkeeping.py | 3 +- .../reviewer_bot/test_app_schedule_repairs.py | 7 +-- .../test_app_state_unavailable.py | 3 +- .../test_app_workflow_run_bookkeeping.py | 6 +- .../test_app_workflow_run_paths.py | 5 +- .../reviewer_bot/test_cli_entrypoint.py | 4 +- .../test_comment_routing_integration.py | 10 +-- .../test_lifecycle_integration.py | 4 +- .../test_reconcile_privileged_command.py | 6 +- .../test_reconcile_workflow_run.py | 17 +---- 20 files changed, 93 insertions(+), 94 deletions(-) diff --git a/.github/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index 39496adea..fc00adb04 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -32,7 +32,52 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Run reviewer bot unit and integration tests with targeted coverage + - name: Run reviewer bot unit tests + run: uv run pytest tests/unit/reviewer_bot + + reviewer-bot-integration: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --dev + + - name: Run reviewer bot integration tests + run: uv run pytest tests/integration/reviewer_bot -m integration + + reviewer-bot-contract: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --dev + + - name: Run reviewer bot contract tests + run: uv run pytest tests/contract/reviewer_bot -m contract + + reviewer-bot-coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --dev + + - name: Run reviewer bot coverage suite run: >- mkdir -p artifacts/coverage && COVERAGE_FILE=artifacts/coverage/.coverage @@ -66,18 +111,3 @@ jobs: f"reviewer-bot branch coverage below floor: {branches:.2f}% < 48%" ) PY - - reviewer-bot-contract: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - - - name: Install dependencies - run: uv sync --dev - - - name: Run reviewer bot contract tests - run: uv run pytest tests/contract/reviewer_bot diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index d0cea9b3d..989c6a015 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -1,4 +1,7 @@ from typing import get_type_hints +import pytest + +pytestmark = pytest.mark.contract from scripts import reviewer_bot from scripts.reviewer_bot_lib.context import ( @@ -9,48 +12,40 @@ ) from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime - def test_reviewer_bot_exports_runtime_modules(): assert reviewer_bot.requests is not None assert reviewer_bot.sys is not None assert reviewer_bot.datetime is not None assert reviewer_bot.timezone is not None - def test_reviewer_bot_satisfies_runtime_context_protocol(): assert isinstance(reviewer_bot, ReviewerBotContext) - def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): assert isinstance(reviewer_bot, GitHubTransportContext) assert isinstance(reviewer_bot, StateStoreContext) assert isinstance(reviewer_bot, LeaseLockContext) - def test_render_lock_commit_message_uses_direct_json_import(): rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) assert reviewer_bot.LOCK_COMMIT_MARKER in rendered - def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult - def test_runtime_context_protocol_exposes_structured_head_repair_contract(): hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult - def test_runtime_bot_returns_concrete_runtime_object(): runtime = reviewer_bot._runtime_bot() assert isinstance(runtime, ReviewerBotRuntime) assert runtime.EVENT_INTENT_MUTATING == reviewer_bot.EVENT_INTENT_MUTATING - def test_build_event_context_returns_structured_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -64,7 +59,6 @@ def test_build_event_context_returns_structured_context(monkeypatch): assert context.workflow_run_event == "pull_request_review" assert context.issue_labels == ("coding guideline",) - def test_execute_run_returns_execution_result(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") diff --git a/tests/contract/reviewer_bot/test_privileged_commands_workflow.py b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py index d473eaeb4..661995933 100644 --- a/tests/contract/reviewer_bot/test_privileged_commands_workflow.py +++ b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest +pytestmark = pytest.mark.contract def test_privileged_commands_workflow_executes_source_entrypoint(): workflow_text = Path(".github/workflows/reviewer-bot-privileged-commands.yml").read_text( diff --git a/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py index babf51b13..03bab4f5b 100644 --- a/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py +++ b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py @@ -1,7 +1,9 @@ from pathlib import Path +import pytest -import yaml +pytestmark = pytest.mark.contract +import yaml def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) @@ -13,7 +15,6 @@ def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): assert issue_number_input["required"] is False assert issue_number_input["type"] == "string" - def test_sweeper_repair_workflow_scopes_reviewer_board_env_to_preview_only(): workflow_text = Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8") assert "ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}" in workflow_text diff --git a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py index 1e3ab13d0..2d53adcfa 100644 --- a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py +++ b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py @@ -1,9 +1,9 @@ from pathlib import Path - import pytest -from scripts import reviewer_bot +pytestmark = pytest.mark.contract +from scripts import reviewer_bot @pytest.mark.parametrize( ("workflow_path", "artifact_name", "payload_name"), @@ -38,7 +38,6 @@ def test_observer_workflow_files_match_expected_artifact_contract( assert artifact_name in workflow_text assert payload_name in workflow_text - @pytest.mark.parametrize( ("payload", "workflow_name", "workflow_file", "artifact_name", "payload_name"), [ @@ -106,7 +105,6 @@ def test_deferred_workflow_identity_helpers_match_expected_contract( assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name - def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") @@ -122,7 +120,6 @@ def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatc with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") @@ -139,7 +136,6 @@ def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(mo with pytest.raises(RuntimeError, match="run_attempt mismatch"): reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) - def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "failure") diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py index 6e7901d81..e7a09f7a6 100644 --- a/tests/contract/reviewer_bot/test_workflow_files.py +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -1,7 +1,9 @@ from pathlib import Path +import pytest -import yaml +pytestmark = pytest.mark.contract +import yaml def test_issue_comment_direct_workflow_exports_issue_state(): workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text( @@ -9,14 +11,12 @@ def test_issue_comment_direct_workflow_exports_issue_state(): ) assert "ISSUE_STATE: ${{ github.event.issue.state }}" in workflow_text - def test_pr_comment_observer_workflow_builds_payload_inline_without_bot_src_root(): workflow = Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8") assert "BOT_SRC_ROOT" not in workflow assert "build_pr_comment_observer_payload" not in workflow assert "Fetch trusted bot source tarball" not in workflow - def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-trusted.yml").read_text(encoding="utf-8")) job = data["jobs"]["reviewer-bot-pr-comment-trusted"] @@ -31,7 +31,6 @@ def test_trusted_pr_comment_workflow_preflights_same_repo_before_mutation(): assert "https://api.github.com/repos/{repo}/pulls/{pr_number}" in workflow_text assert "RUN_TRUSTED_PR_COMMENT" in workflow_text - def test_pr_comment_observer_workflow_uses_inline_payload_builder(): data = yaml.safe_load(Path(".github/workflows/reviewer-bot-pr-comment-observer.yml").read_text(encoding="utf-8")) job = data["jobs"]["observer"] @@ -42,7 +41,6 @@ def test_pr_comment_observer_workflow_uses_inline_payload_builder(): assert "build_pr_comment_observer_payload" not in workflow_text assert 'uv run --project "$BOT_SRC_ROOT"' not in workflow_text - def test_review_comment_observer_workflow_exists_and_is_read_only(): data = yaml.safe_load( Path(".github/workflows/reviewer-bot-pr-review-comment-observer.yml").read_text(encoding="utf-8") @@ -60,7 +58,6 @@ def test_review_comment_observer_workflow_exists_and_is_read_only(): assert "checkout" not in workflow_text assert "pull_request_review_comment" in workflow_text - def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency(): workflow_paths = [ ".github/workflows/reviewer-bot-issues.yml", @@ -76,7 +73,6 @@ def test_mutating_reviewer_bot_workflows_do_not_share_global_github_concurrency( for job in data.get("jobs", {}).values(): assert "concurrency" not in job - def test_workflow_policy_split_and_lock_only_boundaries(): workflows_dir = Path(".github/workflows") required = { @@ -111,14 +107,12 @@ def test_workflow_policy_split_and_lock_only_boundaries(): if value: assert "@" in value and len(value.split("@", 1)[1]) == 40 - def test_workflow_summaries_and_runbook_references_exist(): runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md") assert runbook.exists() reconcile = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8") assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile - def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): from scripts import reviewer_bot diff --git a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py index 4b7e920c9..d343b0db2 100644 --- a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -1,12 +1,12 @@ import json import subprocess - import pytest +pytestmark = pytest.mark.integration + from builder import build_cli from scripts import reviewer_bot - def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): commands_seen = [] @@ -23,7 +23,6 @@ def fake_run_command(command, cwd, check=True): assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] - def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): def fake_run_command(command, cwd, check=True): if command == ["git", "diff", "--name-only"]: @@ -36,7 +35,6 @@ def fake_run_command(command, cwd, check=True): assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] - def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -55,7 +53,6 @@ def fake_list_changed_files(repo_root): assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) assert observed["cwd"] == tmp_path - def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -86,7 +83,6 @@ def fake_run_command(command, cwd, check=False): (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), ] - def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -110,7 +106,6 @@ def fake_run_command(command, cwd, check=False): assert "Audit command failed." in message assert "--locked was provided" in message - def test_update_spec_lock_file_mode_exits_before_build_docs(monkeypatch, tmp_path): monkeypatch.setattr( build_cli.argparse.ArgumentParser, diff --git a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py index aa661ca95..f81fbca7c 100644 --- a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -1,6 +1,8 @@ from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state +import pytest +pytestmark = pytest.mark.integration def test_execute_run_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") @@ -54,7 +56,6 @@ def fake_load_state(*, fail_on_unavailable=False): assert sync_calls[0][0] is reloaded_state assert sync_calls[0][1] == [42] - def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") diff --git a/tests/integration/reviewer_bot/test_app_execution.py b/tests/integration/reviewer_bot/test_app_execution.py index 9e3e2aed7..fe440a393 100644 --- a/tests/integration/reviewer_bot/test_app_execution.py +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -1,6 +1,8 @@ from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state +import pytest +pytestmark = pytest.mark.integration def test_execute_run_reloads_state_before_syncing_status_labels(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") @@ -58,7 +60,6 @@ def fake_sync_status_labels_for_items(state, issue_numbers): "sync", ] - def test_execute_run_returns_failure_when_save_state_fails(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") @@ -75,7 +76,6 @@ def test_execute_run_returns_failure_when_save_state_fails(monkeypatch): assert result.exit_code == 1 assert result.state_changed is True - def test_execute_run_returns_failure_for_invalid_workflow_run_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py index cfaf49624..ed4a989f6 100644 --- a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -1,6 +1,8 @@ from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata +import pytest +pytestmark = pytest.mark.integration def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") @@ -41,7 +43,6 @@ def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, output = capsys.readouterr().out assert "Reviewer board preview skipped: reviewer board is disabled." in output - def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -71,7 +72,6 @@ def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypa assert result.exit_code == 1 assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err - def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") @@ -107,7 +107,6 @@ def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monke assert result.exit_code == 1 assert "Missing reviewer board field: Review State" in capsys.readouterr().err - def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") diff --git a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py index f7b54203b..853dc02bc 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -1,6 +1,8 @@ from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state +import pytest +pytestmark = pytest.mark.integration def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") @@ -56,7 +58,6 @@ def fake_sweep(bot, current): assert result.exit_code == 0 assert save_calls == [["pull_request_review:500"]] - def test_execute_run_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py index 767866141..9332fb4a1 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_repairs.py +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -1,9 +1,11 @@ import json +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state - def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -39,7 +41,6 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re assert synced_issue_numbers == [42, 99] assert saved_epochs[-1] == reviewer_bot.STATUS_PROJECTION_EPOCH - def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync_failure(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") @@ -73,7 +74,6 @@ def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync assert result.exit_code == 0 assert all(epoch != reviewer_bot.STATUS_PROJECTION_EPOCH for epoch in saved_epochs) - def test_execute_run_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -125,7 +125,6 @@ def fake_save_state(updated_state): assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" assert len(saved_states) >= 2 - def test_schedule_overdue_check_does_not_repeat_warning_after_stale_review_repair(monkeypatch): monkeypatch.setenv("EVENT_NAME", "schedule") monkeypatch.setenv("EVENT_ACTION", "") diff --git a/tests/integration/reviewer_bot/test_app_state_unavailable.py b/tests/integration/reviewer_bot/test_app_state_unavailable.py index dc1f294ae..16faa18cc 100644 --- a/tests/integration/reviewer_bot/test_app_state_unavailable.py +++ b/tests/integration/reviewer_bot/test_app_state_unavailable.py @@ -1,5 +1,7 @@ from scripts import reviewer_bot +import pytest +pytestmark = pytest.mark.integration def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") @@ -18,7 +20,6 @@ def fail_load(*, fail_on_unavailable=False): assert result.exit_code == 1 assert result.state_changed is False - def test_execute_run_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py index 9c5d240cd..46b600a9d 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -1,9 +1,11 @@ import json +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state - def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -103,7 +105,6 @@ def fake_github_api(method, endpoint, data=None): assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] assert synced_issue_numbers == [42] - def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): @@ -187,7 +188,6 @@ def fake_github_api(method, endpoint, data=None): assert result.exit_code == 0 assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] - def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py index 1def37964..0cf5c278b 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -1,6 +1,8 @@ from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state +import pytest +pytestmark = pytest.mark.integration def test_execute_run_cross_repo_review_does_not_acquire_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") @@ -21,7 +23,6 @@ def fail_if_called(): assert acquire_called["value"] is False - def test_execute_run_same_repo_review_does_not_acquire_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") @@ -42,7 +43,6 @@ def fail_if_called(): assert acquire_called["value"] is False - def test_execute_run_workflow_run_reconcile_acquires_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -73,7 +73,6 @@ def fake_acquire(): assert acquire_called["value"] is True - def test_execute_run_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index 284571616..c06124551 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -1,7 +1,8 @@ import json - import pytest +pytestmark = pytest.mark.integration + from scripts import reviewer_bot from scripts.reviewer_bot_lib.lifecycle import HeadObservationRepairResult from tests.fixtures.reviewer_bot import make_state @@ -9,7 +10,6 @@ valid_reviewer_board_metadata as _valid_reviewer_board_metadata, ) - def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") monkeypatch.setenv("EVENT_ACTION", "") diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index 3a30dd62a..4843b2cfb 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -1,9 +1,11 @@ import json +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state - def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): state = make_state() entry = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -27,7 +29,6 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypa assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" assert pending["issue_comment:100"]["authorization"]["authorized"] is True - def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -43,7 +44,6 @@ def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypat assert reviewer_bot.handle_comment_event(state) is False assert state["active_reviews"] == {} - def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -69,7 +69,6 @@ def test_closed_non_pr_command_comment_does_not_create_pending_privileged_comman assert state["active_reviews"] == {} assert called["post_comment"] == 0 - def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -88,7 +87,6 @@ def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): assert reviewer_bot.handle_comment_event(state) is True assert "42" not in state["active_reviews"] - def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): state = make_state() monkeypatch.setenv("IS_PULL_REQUEST", "false") @@ -104,7 +102,6 @@ def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): assert reviewer_bot.handle_comment_event(state) is False assert state["active_reviews"] == {} - def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -124,7 +121,6 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted["semantic_key"] == "issue_comment:100" - def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): state = make_state() reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/integration/reviewer_bot/test_lifecycle_integration.py b/tests/integration/reviewer_bot/test_lifecycle_integration.py index b6a261147..2724d29d4 100644 --- a/tests/integration/reviewer_bot/test_lifecycle_integration.py +++ b/tests/integration/reviewer_bot/test_lifecycle_integration.py @@ -1,9 +1,11 @@ import json +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state - def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py index 80fee31ff..344d581ef 100644 --- a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -1,10 +1,12 @@ import json import os +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from tests.fixtures.reviewer_bot import make_state - def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -29,7 +31,6 @@ def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): assert reviewer_bot.handle_manual_dispatch(state) is True assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -69,7 +70,6 @@ def fake_handle(issue_number, actor): } assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" - def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 3b5caf6d0..4d64cecbf 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -1,11 +1,13 @@ import json import os +import pytest + +pytestmark = pytest.mark.integration from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing from tests.fixtures.reviewer_bot import make_state - def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations( tmp_path, monkeypatch ): @@ -74,7 +76,6 @@ def fake_github_api(method, endpoint, data=None): assert "pull_request_review:11" in review["reconciled_source_events"] assert "pull_request_review:11" not in review["deferred_gaps"] - def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -140,7 +141,6 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k assert gap["reason"] == "reconcile_failed_closed" assert gap["failure_kind"] == "server_error" - def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -196,7 +196,6 @@ def fake_github_api(method, endpoint, data=None): assert "issue_comment:210" in review["reconciled_source_events"] assert "issue_comment:210" not in review["deferred_gaps"] - def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -240,7 +239,6 @@ def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tm assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" - def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -305,7 +303,6 @@ def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_i assert reviewer_bot.handle_workflow_run_event(state) is True assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False - def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review( tmp_path, monkeypatch ): @@ -391,7 +388,6 @@ def fake_github_api(method, endpoint, data=None): assert accepted["semantic_key"] == "pull_request_review:10" assert accepted["reviewed_head_sha"] == "head-1" - def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -451,7 +447,6 @@ def fake_github_api(method, endpoint, data=None): assert accepted is not None assert accepted["semantic_key"] == "pull_request_review_comment:301" - def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -509,7 +504,6 @@ def fake_github_api(method, endpoint, data=None): assert reviewer_bot.handle_workflow_run_event(state) is True assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" - def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -558,7 +552,6 @@ def test_deferred_review_comment_missing_live_object_preserves_source_time_fresh assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" - def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -631,7 +624,6 @@ def fake_github_api(method, endpoint, data=None): assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] - def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -688,7 +680,6 @@ def fake_github_api(method, endpoint, data=None): assert os.environ["ISSUE_AUTHOR"] == "dana" assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] - def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): state = make_state() state["queue"] = [{"github": "bob", "name": "Bob"}] @@ -782,7 +773,6 @@ def fake_request(issue_number, username): assert state["active_reviews"]["42"]["current_reviewer"] == "bob" assert posted_comments - def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -850,7 +840,6 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k assert gap["reason"] == "reconcile_failed_closed" assert gap["failure_kind"] == "server_error" - def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) From e72805b6344cbc42f3da620ea8887f69575b87dd Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 08:42:41 +0900 Subject: [PATCH 063/209] refactor(reviewer-bot): type comment routing requests --- scripts/reviewer_bot.py | 21 +- scripts/reviewer_bot_lib/comment_routing.py | 268 ++++++++++++++------ scripts/reviewer_bot_lib/context.py | 28 ++ 3 files changed, 242 insertions(+), 75 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 42b0d1cc8..aded208d9 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -158,7 +158,12 @@ StateIssueSnapshot, get_commands_help, ) -from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult # noqa: F401 +from scripts.reviewer_bot_lib.context import ( # noqa: F401 + CommentEventRequest, + EventContext, + ExecutionResult, + PrCommentTrustContext, +) from scripts.reviewer_bot_lib.guidance import ( # noqa: F401 get_fls_audit_guidance, get_issue_guidance, @@ -888,11 +893,21 @@ def handle_closed_event(state: dict) -> bool: def handle_comment_event(state: dict) -> bool: - return comment_routing_module.handle_comment_event(_runtime_bot(), state) + return comment_routing_module.handle_comment_event( + _runtime_bot(), + state, + comment_routing_module.build_comment_event_request(), + comment_routing_module.build_pr_comment_trust_context(), + ) def build_pr_comment_observer_payload(issue_number: int) -> dict: - return comment_routing_module.build_pr_comment_observer_payload(_runtime_bot(), issue_number) + return comment_routing_module.build_pr_comment_observer_payload( + _runtime_bot(), + issue_number, + comment_routing_module.build_comment_event_request(issue_number=issue_number), + comment_routing_module.build_pr_comment_trust_context(), + ) def handle_manual_dispatch(state: dict) -> bool: diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index f1f36e87b..4230ae179 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -7,6 +7,8 @@ import re from datetime import datetime, timezone +from .context import CommentEventRequest, PrCommentTrustContext + def _now() -> datetime: return datetime.now(timezone.utc) @@ -20,16 +22,40 @@ def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" -def _is_pr_event() -> bool: - return os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" - - -def _issue_state() -> str: - return os.environ.get("ISSUE_STATE", "").strip().lower() - - -def _require_v18_for_pr(state: dict, context: str) -> bool: - if not _is_pr_event(): +def build_comment_event_request(*, issue_number: int | None = None) -> CommentEventRequest: + return CommentEventRequest( + issue_number=issue_number if issue_number is not None else int(os.environ.get("ISSUE_NUMBER", "0") or 0), + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_state=os.environ.get("ISSUE_STATE", "").strip().lower(), + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), + comment_author=os.environ.get("COMMENT_AUTHOR", ""), + comment_author_id=int(os.environ.get("COMMENT_AUTHOR_ID", "0") or 0), + comment_body=os.environ.get("COMMENT_BODY", ""), + comment_created_at=os.environ.get("COMMENT_CREATED_AT", ""), + comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), + comment_user_type=os.environ.get("COMMENT_USER_TYPE", "").strip(), + comment_sender_type=os.environ.get("COMMENT_SENDER_TYPE", "").strip(), + comment_installation_id=os.environ.get("COMMENT_INSTALLATION_ID", "").strip(), + comment_performed_via_github_app=( + os.environ.get("COMMENT_PERFORMED_VIA_GITHUB_APP", "").strip().lower() == "true" + ), + ) + + +def build_pr_comment_trust_context() -> PrCommentTrustContext: + return PrCommentTrustContext( + github_repository=os.environ.get("GITHUB_REPOSITORY", ""), + comment_author_association=os.environ.get("COMMENT_AUTHOR_ASSOCIATION", "").strip(), + current_workflow_file=os.environ.get("CURRENT_WORKFLOW_FILE", "").strip(), + github_ref=os.environ.get("GITHUB_REF", "").strip(), + github_run_id=int(os.environ.get("GITHUB_RUN_ID", "0") or 0), + github_run_attempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "0") or 0), + ) + + +def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) -> bool: + if not request.is_pull_request: return True epoch = _runtime_epoch(state) if epoch != "freshness_v15": @@ -93,21 +119,25 @@ def classify_comment_payload(bot, body: str) -> dict: } -def classify_issue_comment_actor() -> str: - comment_user_type = os.environ.get("COMMENT_USER_TYPE", "").strip() - comment_author = os.environ.get("COMMENT_AUTHOR", "").strip() - sender_type = os.environ.get("COMMENT_SENDER_TYPE", "").strip() - installation_id = os.environ.get("COMMENT_INSTALLATION_ID", "").strip() - via_github_app = os.environ.get("COMMENT_PERFORMED_VIA_GITHUB_APP", "").strip().lower() +def _classify_issue_comment_actor(request: CommentEventRequest) -> str: + comment_user_type = request.comment_user_type + comment_author = request.comment_author.strip() + sender_type = request.comment_sender_type + installation_id = request.comment_installation_id + via_github_app = request.comment_performed_via_github_app if comment_user_type == "Bot" or comment_author.endswith("[bot]"): return "bot_account" - if installation_id or via_github_app == "true" or (sender_type and sender_type not in {"User", "Bot"}): + if installation_id or via_github_app or (sender_type and sender_type not in {"User", "Bot"}): return "github_app_or_other_automation" - if comment_user_type == "User" and comment_author and not comment_author.endswith("[bot]") and not installation_id and via_github_app != "true": + if comment_user_type == "User" and comment_author and not comment_author.endswith("[bot]") and not installation_id and not via_github_app: return "repo_user_principal" return "unknown_actor" +def classify_issue_comment_actor(request: CommentEventRequest | None = None) -> str: + return _classify_issue_comment_actor(request or build_comment_event_request()) + + def _is_self_comment(bot, author: str) -> bool: return author.strip().lower() == bot.BOT_NAME.lower() or author.strip().lower() == bot.BOT_MENTION.lstrip("@").lower() @@ -121,19 +151,23 @@ def _fetch_pr_metadata(bot, issue_number: int) -> dict: return pull_request -def classify_pr_comment_processing_target(bot, issue_number: int) -> str: - actor_class = classify_issue_comment_actor() - if actor_class in {"bot_account", "github_app_or_other_automation"} or _is_self_comment(bot, os.environ.get("COMMENT_AUTHOR", "")): +def _classify_pr_comment_processing_target( + bot, + request: CommentEventRequest, + trust_context: PrCommentTrustContext, +) -> str: + actor_class = _classify_issue_comment_actor(request) + if actor_class in {"bot_account", "github_app_or_other_automation"} or _is_self_comment(bot, request.comment_author): return "safe_noop" - pull_request = _fetch_pr_metadata(bot, issue_number) + pull_request = _fetch_pr_metadata(bot, request.issue_number) head_repo = pull_request.get("head", {}).get("repo", {}) head_full_name = head_repo.get("full_name") if isinstance(head_repo, dict) else None if not isinstance(head_full_name, str) or not head_full_name: raise RuntimeError("Missing PR head repository metadata for trust routing") - is_cross_repo = head_full_name != os.environ.get("GITHUB_REPOSITORY", "") + is_cross_repo = head_full_name != trust_context.github_repository pr_author = pull_request.get("user", {}).get("login") is_dependabot_restricted = pr_author == "dependabot[bot]" - author_association = os.environ.get("COMMENT_AUTHOR_ASSOCIATION", "").strip() + author_association = trust_context.comment_author_association trusted_principal = actor_class == "repo_user_principal" and author_association in bot.AUTHOR_ASSOCIATION_TRUST_ALLOWLIST if is_cross_repo or is_dependabot_restricted: return "pr_deferred_reconcile" @@ -142,40 +176,76 @@ def classify_pr_comment_processing_target(bot, issue_number: int) -> str: raise RuntimeError("Ambiguous same-repo PR comment trust posture; failing closed") -def route_issue_comment_trust(bot, issue_number: int) -> str: - if not _is_pr_event(): +def classify_pr_comment_processing_target( + bot, + issue_number: int, + request: CommentEventRequest | None = None, + trust_context: PrCommentTrustContext | None = None, +) -> str: + comment_request = request or build_comment_event_request(issue_number=issue_number) + return _classify_pr_comment_processing_target( + bot, + comment_request, + trust_context or build_pr_comment_trust_context(), + ) + + +def _route_issue_comment_trust( + bot, + request: CommentEventRequest, + trust_context: PrCommentTrustContext, +) -> str: + if not request.is_pull_request: return "issue_direct" - target = classify_pr_comment_processing_target(bot, issue_number) + target = _classify_pr_comment_processing_target(bot, request, trust_context) if target != "pr_trusted_direct": return target - workflow_file = os.environ.get("CURRENT_WORKFLOW_FILE", "").strip() - workflow_ref = os.environ.get("GITHUB_REF", "").strip() + workflow_file = trust_context.current_workflow_file + workflow_ref = trust_context.github_ref if workflow_file == ".github/workflows/reviewer-bot-pr-comment-trusted.yml" and workflow_ref == "refs/heads/main": return "pr_trusted_direct" raise RuntimeError("Ambiguous same-repo PR comment trust posture; failing closed") -def build_pr_comment_observer_payload(bot, issue_number: int) -> dict: - actor_class = classify_issue_comment_actor() - comment_id = int(os.environ["COMMENT_ID"]) +def route_issue_comment_trust( + bot, + issue_number: int, + request: CommentEventRequest | None = None, + trust_context: PrCommentTrustContext | None = None, +) -> str: + comment_request = request or build_comment_event_request(issue_number=issue_number) + return _route_issue_comment_trust( + bot, + comment_request, + trust_context or build_pr_comment_trust_context(), + ) + + +def _build_pr_comment_observer_payload( + bot, + request: CommentEventRequest, + trust_context: PrCommentTrustContext, +) -> dict: + actor_class = _classify_issue_comment_actor(request) + comment_id = request.comment_id base_payload = { "source_workflow_name": "Reviewer Bot PR Comment Observer", "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": int(os.environ["GITHUB_RUN_ID"]), - "source_run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), + "source_run_id": trust_context.github_run_id, + "source_run_attempt": trust_context.github_run_attempt, "source_event_name": "issue_comment", "source_event_action": "created", "source_event_key": f"issue_comment:{comment_id}", - "pr_number": issue_number, + "pr_number": request.issue_number, } - if actor_class in {"bot_account", "github_app_or_other_automation"} or _is_self_comment(bot, os.environ.get("COMMENT_AUTHOR", "")): + if actor_class in {"bot_account", "github_app_or_other_automation"} or _is_self_comment(bot, request.comment_author): return { "schema_version": 1, "kind": "observer_noop", "reason": "ignored_non_human_automation", **base_payload, } - processing_target = classify_pr_comment_processing_target(bot, issue_number) + processing_target = _classify_pr_comment_processing_target(bot, request, trust_context) if processing_target == "pr_trusted_direct": return { "schema_version": 1, @@ -183,7 +253,7 @@ def build_pr_comment_observer_payload(bot, issue_number: int) -> dict: "reason": "trusted_direct_same_repo_human_comment", **base_payload, } - body = os.environ["COMMENT_BODY"] + body = request.comment_body normalized = _normalize_comment_body(body) command_pattern = re.compile(r"^@guidelines\-bot\s+/[A-Za-z0-9?_\-]+(?:\s+.*)?$") lines = [line for line in normalized.splitlines() if line.strip()] @@ -204,21 +274,59 @@ def build_pr_comment_observer_payload(bot, issue_number: int) -> dict: "comment_class": comment_class, "has_non_command_text": bool(non_command_lines), "source_body_digest": _digest_body(body), - "source_created_at": os.environ["COMMENT_CREATED_AT"], - "actor_login": os.environ["COMMENT_AUTHOR"], - "actor_id": int(os.environ["COMMENT_AUTHOR_ID"]), + "source_created_at": request.comment_created_at, + "actor_login": request.comment_author, + "actor_id": request.comment_author_id, "actor_class": "repo_user_principal" if actor_class == "repo_user_principal" else "unknown_actor", - "source_artifact_name": f"reviewer-bot-comment-context-{os.environ['GITHUB_RUN_ID']}-attempt-{os.environ['GITHUB_RUN_ATTEMPT']}", + "source_artifact_name": ( + f"reviewer-bot-comment-context-{trust_context.github_run_id}-attempt-" + f"{trust_context.github_run_attempt}" + ), } -def _record_conversation_freshness(bot, state: dict, issue_number: int, comment_author: str, comment_id: int, created_at: str) -> bool: +def build_pr_comment_observer_payload( + bot, + issue_number: int, + request: CommentEventRequest | None = None, + trust_context: PrCommentTrustContext | None = None, +) -> dict: + comment_request = request or build_comment_event_request(issue_number=issue_number) + return _build_pr_comment_observer_payload( + bot, + comment_request, + trust_context or build_pr_comment_trust_context(), + ) + + +def _record_conversation_freshness( + bot, + state: dict, + request_or_issue_number: CommentEventRequest | int, + comment_author: str | None = None, + comment_id: int | None = None, + created_at: str | None = None, +) -> bool: + if isinstance(request_or_issue_number, CommentEventRequest): + request = request_or_issue_number + else: + request = CommentEventRequest( + issue_number=request_or_issue_number, + is_pull_request=False, + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + comment_id=comment_id or 0, + comment_author=comment_author or "", + comment_created_at=created_at or "", + comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), + ) + issue_number = request.issue_number review_data = bot.ensure_review_entry(state, issue_number, create=True) if review_data is None: return False - issue_author = os.environ.get("ISSUE_AUTHOR", "") - semantic_key = os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip() or f"issue_comment:{comment_id}" - if issue_author and issue_author.lower() == comment_author.lower(): + comment_author = request.comment_author + created_at = request.comment_created_at + semantic_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" + if request.issue_author and request.issue_author.lower() == comment_author.lower(): return bot.reviews_module.accept_channel_event( review_data, "contributor_comment", @@ -262,40 +370,45 @@ def _store_pending_privileged_command(review_data: dict, issue_number: int, sour return True -def _validate_accept_no_fls_changes_handoff(bot, issue_number: int, comment_author: str) -> tuple[bool, dict]: - if _is_pr_event(): +def _validate_accept_no_fls_changes_handoff( + bot, + request: CommentEventRequest, +) -> tuple[bool, dict]: + if request.is_pull_request: return False, {"reason": "pull_request_target_not_allowed"} labels = bot.parse_issue_labels() if bot.FLS_AUDIT_LABEL not in labels: return False, {"reason": "missing_fls_audit_label"} - permission_status = bot.get_user_permission_status(comment_author, "triage") + permission_status = bot.get_user_permission_status(request.comment_author, "triage") if permission_status == "unavailable": return False, {"reason": "authorization_unavailable"} if permission_status != "granted": return False, {"reason": "authorization_failed"} return True, { "command_name": "accept-no-fls-changes", - "issue_number": issue_number, - "actor": comment_author, + "issue_number": request.issue_number, + "actor": request.comment_author, "authorization": {"required_permission": "triage", "authorized": True}, - "target": {"kind": "issue", "number": issue_number, "labels": sorted(labels)}, + "target": {"kind": "issue", "number": request.issue_number, "labels": sorted(labels)}, } -def _handle_command(bot, state: dict, issue_number: int, comment_author: str, classified: dict) -> bool: +def _handle_command(bot, state: dict, request: CommentEventRequest, classified: dict) -> bool: + issue_number = request.issue_number + comment_author = request.comment_author command = classified.get("command") args = classified.get("args") or [] if not isinstance(command, str): return False - actor_class = classify_issue_comment_actor() + actor_class = _classify_issue_comment_actor(request) if actor_class in {"unknown_actor", "bot_account", "github_app_or_other_automation"}: return False review_data = bot.ensure_review_entry(state, issue_number, create=True) if review_data is None: return False - source_event_key = os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip() or f"issue_comment:{os.environ.get('COMMENT_ID', '')}" + source_event_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" if command == "accept-no-fls-changes": - is_valid, metadata = _validate_accept_no_fls_changes_handoff(bot, issue_number, comment_author) + is_valid, metadata = _validate_accept_no_fls_changes_handoff(bot, request) if not is_valid: bot.post_comment( issue_number, @@ -358,7 +471,7 @@ def _handle_command(bot, state: dict, issue_number: int, comment_author: str, cl response = f"⚠️ Unknown command `{attempted}`. Commands require a `/` prefix.\n\nTry `{bot.BOT_MENTION} /commands` to see available commands." else: response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{bot.get_commands_help()}" - comment_id = int(os.environ.get("COMMENT_ID", "0") or 0) + comment_id = request.comment_id if comment_id > 0 and command != "_multiple_commands": bot.add_reaction(comment_id, "eyes") if success: @@ -368,38 +481,49 @@ def _handle_command(bot, state: dict, issue_number: int, comment_author: str, cl return state_changed -def _process_comment_event(bot, state: dict, issue_number: int) -> bool: - comment_body = os.environ.get("COMMENT_BODY", "") - comment_author = os.environ.get("COMMENT_AUTHOR", "") - comment_id = int(os.environ.get("COMMENT_ID", "0") or 0) - comment_created_at = os.environ.get("COMMENT_CREATED_AT", "") or _now_iso() - classified = classify_comment_payload(bot, comment_body) +def _process_comment_event(bot, state: dict, request: CommentEventRequest) -> bool: + comment_id = request.comment_id + comment_created_at = request.comment_created_at or _now_iso() + comment_request = CommentEventRequest( + **{**request.__dict__, "comment_created_at": comment_created_at} + ) + classified = classify_comment_payload(bot, comment_request.comment_body) comment_class = classified["comment_class"] state_changed = False if comment_class in {"plain_text", "command_plus_text"} and comment_id > 0: - state_changed = _record_conversation_freshness(bot, state, issue_number, comment_author, comment_id, comment_created_at) or state_changed + state_changed = _record_conversation_freshness(bot, state, comment_request) or state_changed if comment_class in {"command_only", "command_plus_text"} and int(classified.get("command_count", 0)) == 1: - state_changed = _handle_command(bot, state, issue_number, comment_author, classified) or state_changed + state_changed = _handle_command(bot, state, comment_request, classified) or state_changed return state_changed -def handle_comment_event(bot, state: dict) -> bool: +def handle_comment_event( + bot, + state: dict, + request: CommentEventRequest | None = None, + trust_context: PrCommentTrustContext | None = None, +) -> bool: bot.assert_lock_held("handle_comment_event") - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + comment_request = request or build_comment_event_request() + issue_number = comment_request.issue_number if not issue_number: return False bot.collect_touched_item(issue_number) - route = route_issue_comment_trust(bot, issue_number) + route = _route_issue_comment_trust( + bot, + comment_request, + trust_context or build_pr_comment_trust_context(), + ) if route == "safe_noop": return False if route == "issue_direct": - if _issue_state() == "closed": + if comment_request.issue_state == "closed": removed = state.get("active_reviews", {}).pop(str(issue_number), None) print(f"Ignoring direct comment on closed issue #{issue_number}") return removed is not None - return _process_comment_event(bot, state, issue_number) + return _process_comment_event(bot, state, comment_request) if route == "pr_trusted_direct": - if not _require_v18_for_pr(state, "pr_trusted_direct_comment"): + if not _require_v18_for_pr(state, comment_request, "pr_trusted_direct_comment"): return False - return _process_comment_event(bot, state, issue_number) + return _process_comment_event(bot, state, comment_request) raise RuntimeError("Deferred PR comment events must not mutate directly in trusted workflows") diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 7e38d4af5..3cdaf71d7 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -45,6 +45,34 @@ class ExecutionResult: release_failed: bool = False +@dataclass(frozen=True) +class CommentEventRequest: + issue_number: int + is_pull_request: bool + issue_state: str = "" + issue_author: str = "" + comment_id: int = 0 + comment_author: str = "" + comment_author_id: int = 0 + comment_body: str = "" + comment_created_at: str = "" + comment_source_event_key: str = "" + comment_user_type: str = "" + comment_sender_type: str = "" + comment_installation_id: str = "" + comment_performed_via_github_app: bool = False + + +@dataclass(frozen=True) +class PrCommentTrustContext: + github_repository: str = "" + comment_author_association: str = "" + current_workflow_file: str = "" + github_ref: str = "" + github_run_id: int = 0 + github_run_attempt: int = 0 + + @runtime_checkable class GitHubTransportContext(Protocol): """GitHub API transport and mutation surface used by low-level helpers.""" From 42aa41ced48f881aa60efb27a4e1a23ebeb624b5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 08:47:22 +0900 Subject: [PATCH 064/209] refactor(reviewer-bot): type command request inputs --- scripts/reviewer_bot.py | 91 +++++++-- scripts/reviewer_bot_lib/automation.py | 45 ++++- scripts/reviewer_bot_lib/commands.py | 207 ++++++++++++++------ scripts/reviewer_bot_lib/comment_routing.py | 11 +- scripts/reviewer_bot_lib/context.py | 23 +++ 5 files changed, 299 insertions(+), 78 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index aded208d9..fe988baaf 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -159,10 +159,12 @@ get_commands_help, ) from scripts.reviewer_bot_lib.context import ( # noqa: F401 + AssignmentRequest, CommentEventRequest, EventContext, ExecutionResult, PrCommentTrustContext, + PrivilegedCommandRequest, ) from scripts.reviewer_bot_lib.guidance import ( # noqa: F401 get_fls_audit_guidance, @@ -612,17 +614,38 @@ def parse_command(comment_body: str) -> tuple[str, list[str]] | None: def handle_pass_command(state: dict, issue_number: int, comment_author: str, - reason: str | None) -> tuple[str, bool]: - return commands_module.handle_pass_command(_runtime_bot(), state, issue_number, comment_author, reason) + reason: str | None) -> tuple[str, bool]: + return commands_module.handle_pass_command( + _runtime_bot(), + state, + issue_number, + comment_author, + reason, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def handle_pass_until_command(state: dict, issue_number: int, comment_author: str, - return_date: str, reason: str | None) -> tuple[str, bool]: - return commands_module.handle_pass_until_command(_runtime_bot(), state, issue_number, comment_author, return_date, reason) + return_date: str, reason: str | None) -> tuple[str, bool]: + return commands_module.handle_pass_until_command( + _runtime_bot(), + state, + issue_number, + comment_author, + return_date, + reason, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def handle_label_command(state: dict, issue_number: int, label_string: str) -> tuple[str, bool, bool]: - return commands_module.handle_label_command(_runtime_bot(), state, issue_number, label_string) + return commands_module.handle_label_command( + _runtime_bot(), + state, + issue_number, + label_string, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def parse_issue_labels() -> list[str]: @@ -654,7 +677,10 @@ def find_open_pr_for_branch_status(branch: str) -> tuple[str, dict | None]: def resolve_workflow_run_pr_number() -> int: - return commands_module.resolve_workflow_run_pr_number(_runtime_bot()) + return commands_module.resolve_workflow_run_pr_number( + _runtime_bot(), + request=commands_module.build_privileged_command_request(issue_number=0), + ) def create_pull_request(branch: str, base: str, issue_number: int) -> dict | None: @@ -662,7 +688,16 @@ def create_pull_request(branch: str, base: str, issue_number: int) -> dict | Non def handle_accept_no_fls_changes_command(issue_number: int, comment_author: str) -> tuple[str, bool]: - return automation_module.handle_accept_no_fls_changes_command(_runtime_bot(), issue_number, comment_author) + return automation_module.handle_accept_no_fls_changes_command( + _runtime_bot(), + issue_number, + comment_author, + request=automation_module.build_privileged_command_request( + issue_number=issue_number, + actor=comment_author, + command_name="accept-no-fls-changes", + ), + ) def handle_sync_members_command(state: dict) -> tuple[str, bool]: @@ -670,7 +705,11 @@ def handle_sync_members_command(state: dict) -> tuple[str, bool]: def handle_queue_command(state: dict) -> tuple[str, bool]: - return commands_module.handle_queue_command(_runtime_bot(), state) + return commands_module.handle_queue_command( + _runtime_bot(), + state, + request=commands_module.build_assignment_request(issue_number=0), + ) def handle_commands_command() -> tuple[str, bool]: @@ -678,22 +717,46 @@ def handle_commands_command() -> tuple[str, bool]: def handle_claim_command(state: dict, issue_number: int, - comment_author: str) -> tuple[str, bool]: - return commands_module.handle_claim_command(_runtime_bot(), state, issue_number, comment_author) + comment_author: str) -> tuple[str, bool]: + return commands_module.handle_claim_command( + _runtime_bot(), + state, + issue_number, + comment_author, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def handle_release_command(state: dict, issue_number: int, - comment_author: str, args: list | None = None) -> tuple[str, bool]: - return commands_module.handle_release_command(_runtime_bot(), state, issue_number, comment_author, args) + comment_author: str, args: list | None = None) -> tuple[str, bool]: + return commands_module.handle_release_command( + _runtime_bot(), + state, + issue_number, + comment_author, + args, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def handle_assign_command(state: dict, issue_number: int, username: str) -> tuple[str, bool]: - return commands_module.handle_assign_command(_runtime_bot(), state, issue_number, username) + return commands_module.handle_assign_command( + _runtime_bot(), + state, + issue_number, + username, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) def handle_assign_from_queue_command(state: dict, issue_number: int) -> tuple[str, bool]: - return commands_module.handle_assign_from_queue_command(_runtime_bot(), state, issue_number) + return commands_module.handle_assign_from_queue_command( + _runtime_bot(), + state, + issue_number, + request=commands_module.build_assignment_request(issue_number=issue_number), + ) # ============================================================================== diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 72e8f40eb..5fc7da835 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -5,6 +5,8 @@ from datetime import datetime, timezone from pathlib import Path +from .context import PrivilegedCommandRequest + def run_command(command: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: result = subprocess.run(command, cwd=cwd, capture_output=True, text=True) @@ -39,6 +41,31 @@ def get_target_repo_root() -> Path: return Path(__file__).resolve().parents[2] +def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: + target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() + return PrivilegedCommandRequest( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=tuple(bot_label for bot_label in bot_parse_issue_labels()), + target_repo_root=target_repo_root, + ) + + +def bot_parse_issue_labels() -> list[str]: + labels_json = os.environ.get("ISSUE_LABELS", "[]") + try: + import json + + labels = json.loads(labels_json) + except Exception: + labels = [] + if not isinstance(labels, list): + return [] + return [str(label) for label in labels] + + def get_default_branch(bot) -> str: repo_info = bot.github_api("GET", "") if isinstance(repo_info, dict): @@ -96,10 +123,20 @@ def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict return None -def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: str) -> tuple[str, bool]: - if os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": +def handle_accept_no_fls_changes_command( + bot, + issue_number: int, + comment_author: str, + request: PrivilegedCommandRequest | None = None, +) -> tuple[str, bool]: + privileged_request = request or build_privileged_command_request( + issue_number=issue_number, + actor=comment_author, + command_name="accept-no-fls-changes", + ) + if privileged_request.is_pull_request: return "❌ This command can only be used on issues, not PRs.", False - labels = bot.parse_issue_labels() + labels = list(privileged_request.issue_labels) if bot.FLS_AUDIT_LABEL not in labels: return "❌ This command is only available on issues labeled `fls-audit`.", False permission_status = bot.get_user_permission_status(comment_author, "triage") @@ -108,7 +145,7 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: if permission_status != "granted": return "❌ You must have triage permissions to run this command.", False - repo_root = get_target_repo_root() + repo_root = Path(privileged_request.target_repo_root) if privileged_request.target_repo_root else get_target_repo_root() if bot.list_changed_files(repo_root): return "❌ Working tree is not clean; refusing to update spec.lock.", False diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index f9e5c8e20..2d0aee214 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -11,9 +11,49 @@ find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status, ) from .config import AssignmentAttempt +from .context import AssignmentRequest, PrivilegedCommandRequest from .guidance import get_issue_guidance, get_pr_guidance +def _parse_issue_labels(labels_json: str) -> list[str]: + try: + labels = json.loads(labels_json) + except json.JSONDecodeError: + labels = [] + if not isinstance(labels, list): + return [] + return [str(label) for label in labels] + + +def build_assignment_request(*, issue_number: int) -> AssignmentRequest: + return AssignmentRequest( + issue_number=issue_number, + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=tuple(_parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]"))), + repo_owner=os.environ.get("REPO_OWNER", ""), + repo_name=os.environ.get("REPO_NAME", ""), + ) + + +def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: + target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() + workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() + return PrivilegedCommandRequest( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=tuple(_parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]"))), + target_repo_root=target_repo_root, + workflow_run_reconcile_pr_number=( + int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None + ), + workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), + workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), + ) + + def strip_code_blocks(comment_body: str) -> str: sanitized = comment_body @@ -87,27 +127,26 @@ def parse_command(bot, comment_body: str) -> tuple[str, list[str]] | None: def _apply_assignment_side_effects( bot, state: dict, - issue_number: int, + request: AssignmentRequest, reviewer: str, assignment_method: str, - issue_author: str, ) -> tuple[AssignmentAttempt, str | None]: - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + issue_number = request.issue_number assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) bot.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) - bot.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") + bot.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: bot.post_comment(issue_number, failure_comment) if assignment_attempt.success: - if is_pr: - bot.post_comment(issue_number, get_pr_guidance(reviewer, issue_author)) + if request.is_pull_request: + bot.post_comment(issue_number, get_pr_guidance(reviewer, request.issue_author)) else: - labels = set(parse_issue_labels()) + labels = set(request.issue_labels) guidance = ( - bot.get_fls_audit_guidance(reviewer, issue_author) + bot.get_fls_audit_guidance(reviewer, request.issue_author) if bot.FLS_AUDIT_LABEL in labels - else get_issue_guidance(reviewer, issue_author) + else get_issue_guidance(reviewer, request.issue_author) ) bot.post_comment(issue_number, guidance) return assignment_attempt, failure_comment @@ -120,7 +159,15 @@ def _current_assignees_or_error(bot, issue_number: int) -> tuple[list[str] | Non return current_assignees, None -def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str, reason: str | None) -> tuple[str, bool]: +def handle_pass_command( + bot, + state: dict, + issue_number: int, + comment_author: str, + reason: str | None, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) issue_data = bot.ensure_review_entry(state, issue_number, create=True) if issue_data is None: return "❌ Unable to load review state.", False @@ -137,10 +184,9 @@ def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str is_first_pass = len(issue_data["skipped"]) == 0 if passed_reviewer not in issue_data["skipped"]: issue_data["skipped"].append(passed_reviewer) - issue_author = os.environ.get("ISSUE_AUTHOR", "") skip_set = set(issue_data["skipped"]) - if issue_author: - skip_set.add(issue_author) + if assignment_request.issue_author: + skip_set.add(assignment_request.issue_author) next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return ("❌ No other reviewers available. Everyone in the queue has either passed on this issue or is the author."), False @@ -149,10 +195,9 @@ def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, - issue_number, + assignment_request, next_reviewer, "round-robin", - issue_author, ) assignment_line = f"@{next_reviewer} is now assigned as the reviewer." if not assignment_attempt.success: @@ -168,7 +213,16 @@ def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str return (f"✅ @{passed_reviewer} has passed this review.{reason_text}\n\n{assignment_line}\n\n_@{original_passer} remains next in queue for future issues._"), True -def handle_pass_until_command(bot, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None) -> tuple[str, bool]: +def handle_pass_until_command( + bot, + state: dict, + issue_number: int, + comment_author: str, + return_date: str, + reason: str | None, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) try: parsed_date = datetime.strptime(return_date, "%Y-%m-%d").date() except ValueError: @@ -214,17 +268,15 @@ def handle_pass_until_command(bot, state: dict, issue_number: int, comment_autho reassigned_msg = "" if is_current_reviewer: bot.unassign_reviewer(issue_number, comment_author) - issue_author = os.environ.get("ISSUE_AUTHOR", "") - skip_set = {issue_author} if issue_author else set() + skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) if next_reviewer: assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, - issue_number, + assignment_request, next_reviewer, "round-robin", - issue_author, ) if assignment_attempt.success: reassigned_msg = f"\n\n@{next_reviewer} has been assigned as the new reviewer for this issue." @@ -242,7 +294,14 @@ def handle_pass_until_command(bot, state: dict, issue_number: int, comment_autho return (f"✅ @{comment_author} is now away until {return_date}{reason_text}.\n\nYou'll be automatically added back to the queue on that date.{reassigned_msg}"), True -def handle_label_command(bot, state: dict, issue_number: int, label_string: str) -> tuple[str, bool, bool]: +def handle_label_command( + bot, + state: dict, + issue_number: int, + label_string: str, + request: AssignmentRequest | None = None, +) -> tuple[str, bool, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) pattern = r'(?:(?<=^)|(?<=\s))([+-])(.+?)(?=\s[+-]|\s*$)' matches = re.findall(pattern, label_string) if not matches: @@ -261,7 +320,7 @@ def handle_label_command(bot, state: dict, issue_number: int, label_string: str) all_success = False elif bot.add_label(issue_number, label): results.append(f"✅ Added label `{label}`") - if label == "sign-off: create pr" and os.environ.get("IS_PULL_REQUEST", "false").lower() != "true": + if label == "sign-off: create pr" and not assignment_request.is_pull_request: review_data = bot.ensure_review_entry(state, issue_number) reviewer = review_data.get("current_reviewer") if review_data else None completion_changed = bot.mark_review_complete( @@ -283,14 +342,7 @@ def handle_label_command(bot, state: dict, issue_number: int, label_string: str) def parse_issue_labels() -> list[str]: - labels_json = os.environ.get("ISSUE_LABELS", "[]") - try: - labels = json.loads(labels_json) - except json.JSONDecodeError: - labels = [] - if not isinstance(labels, list): - return [] - return [str(label) for label in labels] + return _parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]")) def run_command(command: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: @@ -340,20 +392,20 @@ def find_open_pr_for_branch(bot, branch: str) -> dict | None: return pr -def resolve_workflow_run_pr_number(bot) -> int: - pr_number_raw = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() - if not pr_number_raw: +def resolve_workflow_run_pr_number( + bot, + request: PrivilegedCommandRequest | None = None, +) -> int: + privileged_request = request or build_privileged_command_request(issue_number=0) + if privileged_request.workflow_run_reconcile_pr_number is None: raise RuntimeError("Missing WORKFLOW_RUN_RECONCILE_PR_NUMBER in workflow_run reconcile context") - try: - pr_number = int(pr_number_raw) - except ValueError as exc: - raise RuntimeError("WORKFLOW_RUN_RECONCILE_PR_NUMBER must be a positive integer") from exc + pr_number = privileged_request.workflow_run_reconcile_pr_number if pr_number <= 0: raise RuntimeError("WORKFLOW_RUN_RECONCILE_PR_NUMBER must be a positive integer") - reconcile_head_sha = os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip() + reconcile_head_sha = privileged_request.workflow_run_reconcile_head_sha if not reconcile_head_sha: raise RuntimeError("Missing WORKFLOW_RUN_RECONCILE_HEAD_SHA in workflow_run reconcile context") - workflow_run_head_sha = os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip() + workflow_run_head_sha = privileged_request.workflow_run_head_sha if not workflow_run_head_sha: raise RuntimeError("Missing WORKFLOW_RUN_HEAD_SHA for workflow_run reconcile") if reconcile_head_sha != workflow_run_head_sha: @@ -390,10 +442,20 @@ def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict return None -def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: str) -> tuple[str, bool]: - if os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": +def handle_accept_no_fls_changes_command( + bot, + issue_number: int, + comment_author: str, + request: PrivilegedCommandRequest | None = None, +) -> tuple[str, bool]: + privileged_request = request or build_privileged_command_request( + issue_number=issue_number, + actor=comment_author, + command_name="accept-no-fls-changes", + ) + if privileged_request.is_pull_request: return "❌ This command can only be used on issues, not PRs.", False - labels = bot.parse_issue_labels() + labels = list(privileged_request.issue_labels) if bot.FLS_AUDIT_LABEL not in labels: return "❌ This command is only available on issues labeled `fls-audit`.", False permission_status = bot.get_user_permission_status(comment_author, "triage") @@ -450,10 +512,15 @@ def handle_sync_members_command(bot, state: dict) -> tuple[str, bool]: return "✅ Queue is already in sync with members.md.", True -def handle_queue_command(bot, state: dict) -> tuple[str, bool]: +def handle_queue_command( + bot, + state: dict, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=0) queue_size = len(state["queue"]) - repo_owner = os.environ.get("REPO_OWNER", "") - repo_name = os.environ.get("REPO_NAME", "") + repo_owner = assignment_request.repo_owner + repo_name = assignment_request.repo_name state_issue_link = "" if repo_owner and repo_name and bot.STATE_ISSUE_NUMBER: state_issue_link = f"\n\n[View full state details](https://github.com/{repo_owner}/{repo_name}/issues/{bot.STATE_ISSUE_NUMBER})" @@ -480,7 +547,14 @@ def handle_commands_command(bot) -> tuple[str, bool]: return (f"ℹ️ **Available Commands**\n\n**Pass or step away:**\n- `{bot.BOT_MENTION} /pass [reason]` - Pass this review to next in queue (current reviewer only)\n- `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from queue until a date\n- `{bot.BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)\n\n**Assign reviewers:**\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Request the next reviewer from the queue\n- `{bot.BOT_MENTION} /claim` - Claim this review for yourself\n\n**Other:**\n- `{bot.BOT_MENTION} /label +label-name` - Add a label\n- `{bot.BOT_MENTION} /label -label-name` - Remove a label\n- `{bot.BOT_MENTION} /rectify` - Reconcile this issue/PR review state from GitHub\n- `{bot.BOT_MENTION} /accept-no-fls-changes` - Update spec.lock and open a PR when no guidelines are impacted\n- `{bot.BOT_MENTION} /queue` - Show current queue status\n- `{bot.BOT_MENTION} /sync-members` - Sync queue with members.md"), True -def handle_claim_command(bot, state: dict, issue_number: int, comment_author: str) -> tuple[str, bool]: +def handle_claim_command( + bot, + state: dict, + issue_number: int, + comment_author: str, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) is_producer = any(member["github"].lower() == comment_author.lower() for member in state["queue"]) is_away = any(member["github"].lower() == comment_author.lower() for member in state.get("pass_until", [])) if not is_producer and not is_away: @@ -492,14 +566,12 @@ def handle_claim_command(bot, state: dict, issue_number: int, comment_author: st return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) - issue_author = os.environ.get("ISSUE_AUTHOR", "") assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, - issue_number, + assignment_request, comment_author, "claim", - issue_author, ) prev_text = f" (previously: @{', @'.join(current_assignees)})" if current_assignees else "" response = f"✅ @{comment_author} has claimed this review{prev_text}." @@ -509,8 +581,16 @@ def handle_claim_command(bot, state: dict, issue_number: int, comment_author: st return response, True -def handle_release_command(bot, state: dict, issue_number: int, comment_author: str, args: list | None = None) -> tuple[str, bool]: +def handle_release_command( + bot, + state: dict, + issue_number: int, + comment_author: str, + args: list | None = None, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: args = args or [] + request = request or build_assignment_request(issue_number=issue_number) target_username = None reason = None releasing_other = False @@ -562,7 +642,14 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author: return (f"✅ @{target_username} has released this review.{reason_text}\n\n_This issue/PR is now unassigned. Use `{bot.BOT_MENTION} /r? producers` to assign the next reviewer from the queue, or `{bot.BOT_MENTION} /claim` to claim it._"), True -def handle_assign_command(bot, state: dict, issue_number: int, username: str) -> tuple[str, bool]: +def handle_assign_command( + bot, + state: dict, + issue_number: int, + username: str, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) username = username.lstrip("@") if not username: return (f"❌ Missing username. Usage: `{bot.BOT_MENTION} /r? @username`"), False @@ -580,14 +667,12 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) -> return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) - issue_author = os.environ.get("ISSUE_AUTHOR", "") assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, - issue_number, + assignment_request, username, "manual", - issue_author, ) prev_text = f" (previously: @{', @'.join(current_assignees)})" if current_assignees else "" if assignment_attempt.success: @@ -598,24 +683,28 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) -> return response, True -def handle_assign_from_queue_command(bot, state: dict, issue_number: int) -> tuple[str, bool]: +def handle_assign_from_queue_command( + bot, + state: dict, + issue_number: int, + request: AssignmentRequest | None = None, +) -> tuple[str, bool]: + assignment_request = request or build_assignment_request(issue_number=issue_number) current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) if assignee_error: return assignee_error, False for assignee in current_assignees: bot.unassign_reviewer(issue_number, assignee) - issue_author = os.environ.get("ISSUE_AUTHOR", "") - skip_set = {issue_author} if issue_author else set() + skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return (f"❌ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue."), False assignment_attempt, _failure_comment = _apply_assignment_side_effects( bot, state, - issue_number, + assignment_request, next_reviewer, "round-robin", - issue_author, ) prev_text = f" (previously: @{', @'.join(current_assignees)})" if current_assignees else "" if assignment_attempt.success: diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 4230ae179..7c84e355b 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -372,8 +372,17 @@ def _store_pending_privileged_command(review_data: dict, issue_number: int, sour def _validate_accept_no_fls_changes_handoff( bot, - request: CommentEventRequest, + request_or_issue_number: CommentEventRequest | int, + comment_author: str | None = None, ) -> tuple[bool, dict]: + if isinstance(request_or_issue_number, CommentEventRequest): + request = request_or_issue_number + else: + request = CommentEventRequest( + issue_number=request_or_issue_number, + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + comment_author=comment_author or "", + ) if request.is_pull_request: return False, {"reason": "pull_request_target_not_allowed"} labels = bot.parse_issue_labels() diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 3cdaf71d7..d28c236f5 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -73,6 +73,29 @@ class PrCommentTrustContext: github_run_attempt: int = 0 +@dataclass(frozen=True) +class AssignmentRequest: + issue_number: int + issue_author: str = "" + is_pull_request: bool = False + issue_labels: tuple[str, ...] = () + repo_owner: str = "" + repo_name: str = "" + + +@dataclass(frozen=True) +class PrivilegedCommandRequest: + issue_number: int + actor: str = "" + command_name: str = "" + is_pull_request: bool = False + issue_labels: tuple[str, ...] = () + target_repo_root: str = "" + workflow_run_reconcile_pr_number: int | None = None + workflow_run_reconcile_head_sha: str = "" + workflow_run_head_sha: str = "" + + @runtime_checkable class GitHubTransportContext(Protocol): """GitHub API transport and mutation surface used by low-level helpers.""" From f64b8b53119548ab64c56515c293197abc0c160b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 08:54:24 +0900 Subject: [PATCH 065/209] refactor(reviewer-bot): harden runtime service seams --- scripts/reviewer_bot.py | 18 ++++- scripts/reviewer_bot_lib/app.py | 65 ++++++++------- scripts/reviewer_bot_lib/context.py | 4 + scripts/reviewer_bot_lib/reconcile.py | 75 ++++++++---------- scripts/reviewer_bot_lib/runtime.py | 110 ++++++++++++++++++++++++-- 5 files changed, 191 insertions(+), 81 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index fe988baaf..ecffb9543 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -207,6 +207,22 @@ def _runtime_bot() -> Any: return RUNTIME +def get_config_value(name: str, default: str = "") -> str: + return _runtime_bot().get_config_value(name, default) + + +def set_config_value(name: str, value: Any) -> None: + _runtime_bot().set_config_value(name, value) + + +def write_output(name: str, value: str) -> None: + _runtime_bot().write_output(name, value) + + +def load_deferred_payload() -> dict: + return _runtime_bot().load_deferred_payload() + + def get_github_token() -> str: return github_api_module.get_github_token() @@ -1004,7 +1020,7 @@ def event_requires_lease_lock(event_name: str, event_action: str) -> bool: def build_event_context() -> EventContext: - return app_module.build_event_context() + return app_module.build_event_context(_runtime_bot()) def execute_run(context: EventContext) -> ExecutionResult: diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 6b3ada105..0f12e63b7 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -1,7 +1,6 @@ """Top-level reviewer-bot orchestration.""" import json -import os import sys from .context import EventContext, ExecutionResult, ReviewerBotContext @@ -82,33 +81,33 @@ def _parse_issue_labels(value: str) -> tuple[str, ...]: return tuple(label for label in payload if isinstance(label, str)) -def build_event_context() -> EventContext: +def build_event_context(bot: ReviewerBotContext) -> EventContext: return EventContext( - event_name=os.environ.get("EVENT_NAME", "").strip(), - event_action=os.environ.get("EVENT_ACTION", "").strip(), - issue_number=_parse_optional_int(os.environ.get("ISSUE_NUMBER", "")), - is_pull_request=_parse_optional_bool(os.environ.get("IS_PULL_REQUEST", "")), - issue_author=os.environ.get("ISSUE_AUTHOR", "").strip() or None, - issue_state=os.environ.get("ISSUE_STATE", "").strip() or None, - issue_labels=_parse_issue_labels(os.environ.get("ISSUE_LABELS", "")), - comment_id=_parse_optional_int(os.environ.get("COMMENT_ID", "")), - comment_author=os.environ.get("COMMENT_AUTHOR", "").strip() or None, - comment_body=os.environ.get("COMMENT_BODY", "") or None, - comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip() or None, - pr_is_cross_repository=_parse_optional_bool(os.environ.get("PR_IS_CROSS_REPOSITORY", "")), - review_author=os.environ.get("REVIEW_AUTHOR", "").strip() or None, - review_state=os.environ.get("REVIEW_STATE", "").strip() or None, - workflow_run_event=os.environ.get("WORKFLOW_RUN_EVENT", "").strip() or None, - workflow_run_event_action=os.environ.get("WORKFLOW_RUN_EVENT_ACTION", "").strip() or None, - workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip() or None, + event_name=bot.get_config_value("EVENT_NAME").strip(), + event_action=bot.get_config_value("EVENT_ACTION").strip(), + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")), + is_pull_request=_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST")), + issue_author=bot.get_config_value("ISSUE_AUTHOR").strip() or None, + issue_state=bot.get_config_value("ISSUE_STATE").strip() or None, + issue_labels=_parse_issue_labels(bot.get_config_value("ISSUE_LABELS")), + comment_id=_parse_optional_int(bot.get_config_value("COMMENT_ID")), + comment_author=bot.get_config_value("COMMENT_AUTHOR").strip() or None, + comment_body=bot.get_config_value("COMMENT_BODY") or None, + comment_source_event_key=bot.get_config_value("COMMENT_SOURCE_EVENT_KEY").strip() or None, + pr_is_cross_repository=_parse_optional_bool(bot.get_config_value("PR_IS_CROSS_REPOSITORY")), + review_author=bot.get_config_value("REVIEW_AUTHOR").strip() or None, + review_state=bot.get_config_value("REVIEW_STATE").strip() or None, + workflow_run_event=bot.get_config_value("WORKFLOW_RUN_EVENT").strip() or None, + workflow_run_event_action=bot.get_config_value("WORKFLOW_RUN_EVENT_ACTION").strip() or None, + workflow_run_head_sha=bot.get_config_value("WORKFLOW_RUN_HEAD_SHA").strip() or None, workflow_run_reconcile_pr_number=_parse_optional_int( - os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "") + bot.get_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER") ), - workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip() or None, - workflow_run_id=_parse_optional_int(os.environ.get("WORKFLOW_RUN_ID", "")), - workflow_name=os.environ.get("WORKFLOW_NAME", "").strip() or None, - workflow_job_name=os.environ.get("WORKFLOW_JOB_NAME", "").strip() or None, - manual_action=os.environ.get("MANUAL_ACTION", "").strip() or None, + workflow_run_reconcile_head_sha=bot.get_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA").strip() or None, + workflow_run_id=_parse_optional_int(bot.get_config_value("WORKFLOW_RUN_ID")), + workflow_name=bot.get_config_value("WORKFLOW_NAME").strip() or None, + workflow_job_name=bot.get_config_value("WORKFLOW_JOB_NAME").strip() or None, + manual_action=bot.get_config_value("MANUAL_ACTION").strip() or None, ) @@ -124,7 +123,7 @@ def _classify_event_intent_from_context(bot: ReviewerBotContext, context: EventC if event_name == "issue_comment": if event_action == "created": if context.is_pull_request is True: - trust_class = os.environ.get("REVIEWER_BOT_TRUST_CLASS", "").strip() + trust_class = bot.get_config_value("REVIEWER_BOT_TRUST_CLASS").strip() if trust_class in {"pr_deferred_reconcile", "safe_noop"}: return bot.EVENT_INTENT_NON_MUTATING_DEFER return bot.EVENT_INTENT_MUTATING @@ -160,7 +159,7 @@ def _classify_event_intent_from_context(bot: ReviewerBotContext, context: EventC def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action: str) -> str: """Classify whether a run can mutate reviewer-bot state.""" - context = build_event_context() + context = build_event_context(bot) context = EventContext( event_name=event_name, event_action=event_action, @@ -307,7 +306,7 @@ def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResu len(current_active_reviews) if isinstance(current_active_reviews, dict) else 0 ) allow_empty_override = ( - os.environ.get("ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", "").strip().lower() == "true" + bot.get_config_value("ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE").strip().lower() == "true" ) if ( loaded_active_reviews_count > 0 @@ -363,10 +362,10 @@ def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResu execution_state_changed = bool(state_changed or sync_changes or restored or status_labels_changed) - with open(os.environ.get("GITHUB_OUTPUT", "/dev/null"), "a") as output_file: - output_file.write( - "state_changed=true\n" if execution_state_changed else "state_changed=false\n" - ) + bot.write_output( + "state_changed", + "true" if execution_state_changed else "false", + ) if projection_failure is not None: print( "PROJECTION_REPAIR_REQUIRED: labels remain unchanged until a trusted repair path succeeds.", @@ -400,6 +399,6 @@ def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResu def main(bot: ReviewerBotContext): """Main entry point for the reviewer bot.""" - result = execute_run(bot, build_event_context()) + result = execute_run(bot, build_event_context(bot)) if result.exit_code: sys.exit(result.exit_code) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index d28c236f5..90e6e77f1 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -247,6 +247,10 @@ class ReviewerBotContext(GitHubTransportContext, StateStoreContext, LeaseLockCon EVENT_INTENT_NON_MUTATING_READONLY: str datetime: type[datetime] timezone: Any + def get_config_value(self, name: str, default: str = "") -> str: ... + def set_config_value(self, name: str, value: Any) -> None: ... + def write_output(self, name: str, value: str) -> None: ... + def load_deferred_payload(self) -> dict: ... def load_state(self, *, fail_on_unavailable: bool = False) -> dict: ... def save_state(self, state: dict) -> bool: ... def ensure_state_issue_lease_lock_fresh(self) -> bool: ... diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 928c7c24c..64a650947 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import os from copy import deepcopy from dataclasses import dataclass @@ -287,9 +286,9 @@ def reconcile_active_review_entry( assigned_reviewer = review_data.get("current_reviewer") if not assigned_reviewer: return f"ℹ️ #{issue_number} has no tracked assigned reviewer; nothing to rectify.", True, False - if require_pull_request_context and os.environ.get("IS_PULL_REQUEST", "false").lower() != "true": + if require_pull_request_context and bot.get_config_value("IS_PULL_REQUEST", "false").lower() != "true": return f"ℹ️ #{issue_number} is not a pull request in this event context; `/rectify` only reconciles PR reviews.", True, False - if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15" and os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": + if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15" and bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true": return "ℹ️ PR review freshness rectify is epoch-gated and currently inactive.", True, False head_repair_result = bot.maybe_record_head_observation_repair(issue_number, review_data) state_changed = head_repair_result.changed @@ -407,15 +406,8 @@ def _validate_deferred_review_comment_artifact(payload: dict) -> None: raise RuntimeError("Deferred review-comment artifact source digest or timestamp is malformed") -def _load_deferred_context() -> dict: - path = os.environ.get("DEFERRED_CONTEXT_PATH", "").strip() - if not path: - raise RuntimeError("Missing DEFERRED_CONTEXT_PATH for workflow_run reconcile") - with open(path, encoding="utf-8") as handle: - payload = json.load(handle) - if not isinstance(payload, dict): - raise RuntimeError("Deferred context payload must be a JSON object") - return payload +def _load_deferred_context(bot) -> dict: + return bot.load_deferred_payload() def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | DeferredCommentPayload | ObserverNoopPayload: @@ -480,10 +472,10 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def raise RuntimeError("Unsupported deferred workflow_run payload") -def _set_env_if_present(name: str, value) -> None: +def _set_config_if_present(bot, name: str, value) -> None: if value is None: return - os.environ[name] = str(value) + bot.set_config_value(name, value) def _hydrate_reconcile_pr_context(bot, pr_number: int) -> dict: @@ -507,13 +499,13 @@ def _hydrate_reconcile_pr_context(bot, pr_number: int) -> dict: if not isinstance(name, str): raise RuntimeError(f"Live PR #{pr_number} contains a label without a valid name") label_names.append(name) - os.environ["IS_PULL_REQUEST"] = "true" - os.environ["ISSUE_AUTHOR"] = author_login - os.environ["ISSUE_LABELS"] = json.dumps(label_names) + bot.set_config_value("IS_PULL_REQUEST", "true") + bot.set_config_value("ISSUE_AUTHOR", author_login) + bot.set_config_value("ISSUE_LABELS", json.dumps(label_names)) return pull_request -def _hydrate_reconcile_comment_context(live_comment: dict, payload: dict) -> None: +def _hydrate_reconcile_comment_context(bot, live_comment: dict, payload: dict) -> None: user = live_comment.get("user") if not isinstance(user, dict): raise RuntimeError("Live deferred comment user metadata is unavailable") @@ -526,15 +518,18 @@ def _hydrate_reconcile_comment_context(live_comment: dict, payload: dict) -> Non author_association = live_comment.get("author_association") if not isinstance(author_association, str) or not author_association.strip(): raise RuntimeError("Live deferred comment author association is unavailable") - _set_env_if_present("COMMENT_AUTHOR", comment_author) - _set_env_if_present("COMMENT_ID", payload.get("comment_id")) - _set_env_if_present("COMMENT_SOURCE_EVENT_KEY", payload.get("source_event_key")) - _set_env_if_present("COMMENT_CREATED_AT", payload.get("source_created_at")) - _set_env_if_present("COMMENT_USER_TYPE", comment_user_type) - _set_env_if_present("COMMENT_AUTHOR_ASSOCIATION", author_association) - _set_env_if_present("COMMENT_SENDER_TYPE", comment_user_type) - os.environ["COMMENT_INSTALLATION_ID"] = "" - os.environ["COMMENT_PERFORMED_VIA_GITHUB_APP"] = "true" if live_comment.get("performed_via_github_app") else "false" + _set_config_if_present(bot, "COMMENT_AUTHOR", comment_author) + _set_config_if_present(bot, "COMMENT_ID", payload.get("comment_id")) + _set_config_if_present(bot, "COMMENT_SOURCE_EVENT_KEY", payload.get("source_event_key")) + _set_config_if_present(bot, "COMMENT_CREATED_AT", payload.get("source_created_at")) + _set_config_if_present(bot, "COMMENT_USER_TYPE", comment_user_type) + _set_config_if_present(bot, "COMMENT_AUTHOR_ASSOCIATION", author_association) + _set_config_if_present(bot, "COMMENT_SENDER_TYPE", comment_user_type) + bot.set_config_value("COMMENT_INSTALLATION_ID", "") + bot.set_config_value( + "COMMENT_PERFORMED_VIA_GITHUB_APP", + "true" if live_comment.get("performed_via_github_app") else "false", + ) def _validate_observer_noop_payload(payload: dict) -> None: @@ -590,22 +585,22 @@ def _expected_observer_identity(payload: dict) -> tuple[str, str]: raise RuntimeError("Unsupported deferred workflow identity") -def _validate_workflow_run_artifact_identity(payload: dict) -> None: +def _validate_workflow_run_artifact_identity(bot, payload: dict) -> None: expected_name, expected_file = _expected_observer_identity(payload) if payload.get("source_workflow_name") != expected_name: raise RuntimeError("Deferred artifact workflow name mismatch") if payload.get("source_workflow_file") != expected_file: raise RuntimeError("Deferred artifact workflow file mismatch") - triggering_name = os.environ.get("WORKFLOW_RUN_TRIGGERING_NAME", "").strip() + triggering_name = bot.get_config_value("WORKFLOW_RUN_TRIGGERING_NAME").strip() if triggering_name and triggering_name != expected_name: raise RuntimeError("Triggering workflow name mismatch") - triggering_id = os.environ.get("WORKFLOW_RUN_TRIGGERING_ID", "").strip() + triggering_id = bot.get_config_value("WORKFLOW_RUN_TRIGGERING_ID").strip() if triggering_id and str(payload.get("source_run_id")) != triggering_id: raise RuntimeError("Deferred artifact run_id mismatch") - triggering_attempt = os.environ.get("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "").strip() + triggering_attempt = bot.get_config_value("WORKFLOW_RUN_TRIGGERING_ATTEMPT").strip() if triggering_attempt and str(payload.get("source_run_attempt")) != triggering_attempt: raise RuntimeError("Deferred artifact run_attempt mismatch") - if os.environ.get("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "").strip() != "success": + if bot.get_config_value("WORKFLOW_RUN_TRIGGERING_CONCLUSION").strip() != "success": raise RuntimeError("Triggering observer workflow did not conclude successfully") @@ -648,7 +643,7 @@ def _reconcile_deferred_comment( payload = context.payload.raw_payload comment_id = context.comment_id pr_number = context.pr_number - _set_env_if_present("COMMENT_SOURCE_EVENT_KEY", context.source_event_key) + _set_config_if_present(bot, "COMMENT_SOURCE_EVENT_KEY", context.source_event_key) _hydrate_reconcile_pr_context(bot, pr_number) comment_author = context.actor_login comment_created_at = context.source_created_at @@ -678,7 +673,7 @@ def _reconcile_deferred_comment( failure_kind=exc.failure_kind, ) return changed or gap_changed - _hydrate_reconcile_comment_context(live_comment, payload) + _hydrate_reconcile_comment_context(bot, live_comment, payload) live_body = live_comment.get("body") if not isinstance(live_body, str): raise RuntimeError("Live deferred comment body is unavailable") @@ -807,7 +802,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15": print("V18 workflow_run reconcile safe-noop before epoch flip") return False - payload = _load_deferred_context() + payload = _load_deferred_context(bot) parsed_payload = parse_deferred_context_payload(payload) pr_number = parsed_payload.pr_number if pr_number <= 0: @@ -821,7 +816,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: source_event_key = parsed_payload.identity.source_event_key try: if isinstance(parsed_payload, ObserverNoopPayload): - _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) print( "Observer workflow produced explicit no-op payload for " f"{source_event_key}: {parsed_payload.reason}" @@ -829,7 +824,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: return False if event_name == "issue_comment" and isinstance(parsed_payload, DeferredCommentPayload): - _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) context = build_deferred_comment_replay_context( parsed_payload, expected_event_name="issue_comment", @@ -843,7 +838,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: ) if event_name == "pull_request_review_comment" and event_action == "created" and isinstance(parsed_payload, DeferredCommentPayload): - _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) context = build_deferred_comment_replay_context( parsed_payload, expected_event_name="pull_request_review_comment", @@ -857,7 +852,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: ) if event_name == "pull_request_review" and event_action == "submitted" and isinstance(parsed_payload, DeferredReviewPayload): - _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) context = build_deferred_review_replay_context( parsed_payload, expected_event_action="submitted", @@ -901,7 +896,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: return state_changed or reconciled_changed or gap_cleared_changed if event_name == "pull_request_review" and event_action == "dismissed" and isinstance(parsed_payload, DeferredReviewPayload): - _validate_workflow_run_artifact_identity(parsed_payload.raw_payload) + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) context = build_deferred_review_replay_context( parsed_payload, expected_event_action="dismissed", diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index d79bcc673..94b9e2953 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -2,26 +2,122 @@ from __future__ import annotations +import json +import os +from pathlib import Path from types import ModuleType from typing import Any -class ReviewerBotRuntime: - """Thin compatibility wrapper around the adapter module. +class _ModuleConfig: + def get(self, name: str, default: str = "") -> str: + return os.environ.get(name, default) + + def set(self, name: str, value: Any) -> None: + os.environ[name] = str(value) + + +class _ModuleOutputSink: + def __init__(self, config: _ModuleConfig): + self._config = config + + def write(self, name: str, value: str) -> None: + output_path = self._config.get("GITHUB_OUTPUT", "/dev/null") + with open(output_path, "a", encoding="utf-8") as output_file: + output_file.write(f"{name}={value}\n") + + +class _ModuleDeferredPayloadLoader: + def __init__(self, config: _ModuleConfig): + self._config = config - The first slice is intentionally structural only: it delegates all runtime - reads and writes to the existing adapter module so current wrappers, - monkeypatching patterns, and mutable state remain behavior-compatible. - """ + def load(self) -> dict: + path = self._config.get("DEFERRED_CONTEXT_PATH", "").strip() + if not path: + raise RuntimeError("Missing DEFERRED_CONTEXT_PATH for workflow_run reconcile") + with open(Path(path), encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise RuntimeError("Deferred context payload must be a JSON object") + return payload + +class _ModuleStateStore: def __init__(self, module: ModuleType): + self._module = module + + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + return self._module.load_state(fail_on_unavailable=fail_on_unavailable) + + def save_state(self, state: dict) -> bool: + return self._module.save_state(state) + + +class _ModuleGitHubTransport: + def __init__(self, module: ModuleType): + self._module = module + + def github_api_request(self, *args, **kwargs): + return self._module.github_api_request(*args, **kwargs) + + def github_api(self, *args, **kwargs): + return self._module.github_api(*args, **kwargs) + + +class ReviewerBotRuntime: + """Runtime service host with adapter-compatible fallbacks.""" + + def __init__( + self, + module: ModuleType, + *, + config: Any | None = None, + outputs: Any | None = None, + deferred_payloads: Any | None = None, + state_store: Any | None = None, + github: Any | None = None, + ): object.__setattr__(self, "_module", module) + config_service = config or _ModuleConfig() + object.__setattr__(self, "_config", config_service) + object.__setattr__(self, "_outputs", outputs or _ModuleOutputSink(config_service)) + object.__setattr__( + self, + "_deferred_payloads", + deferred_payloads or _ModuleDeferredPayloadLoader(config_service), + ) + object.__setattr__(self, "_state_store", state_store or _ModuleStateStore(module)) + object.__setattr__(self, "_github", github or _ModuleGitHubTransport(module)) def __getattr__(self, name: str) -> Any: return getattr(self._module, name) def __setattr__(self, name: str, value: Any) -> None: - if name == "_module": + if name.startswith("_"): object.__setattr__(self, name, value) return setattr(self._module, name, value) + + def get_config_value(self, name: str, default: str = "") -> str: + return self._config.get(name, default) + + def set_config_value(self, name: str, value: Any) -> None: + self._config.set(name, value) + + def write_output(self, name: str, value: str) -> None: + self._outputs.write(name, value) + + def load_deferred_payload(self) -> dict: + return self._deferred_payloads.load() + + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + return self._state_store.load_state(fail_on_unavailable=fail_on_unavailable) + + def save_state(self, state: dict) -> bool: + return self._state_store.save_state(state) + + def github_api_request(self, *args, **kwargs): + return self._github.github_api_request(*args, **kwargs) + + def github_api(self, *args, **kwargs): + return self._github.github_api(*args, **kwargs) From cc8f85b9d0e50436d303691170a80badab182fde Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 09:01:20 +0900 Subject: [PATCH 066/209] test(reviewer-bot): add review scenario builders --- tests/fixtures/__init__.py | 39 +++++ tests/fixtures/reviewer_bot.py | 44 +++++ tests/fixtures/reviewer_bot_builders.py | 164 ++++++++++++++++++ tests/unit/reviewer_bot/test_project_board.py | 94 +++++----- .../reviewer_bot/test_reviews_projection.py | 45 +++-- 5 files changed, 326 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/reviewer_bot_builders.py diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index e69de29bb..c0970cc35 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -0,0 +1,39 @@ +from .reviewer_bot_builders import ( + accept_contributor_comment as accept_contributor_comment, +) +from .reviewer_bot_builders import ( + accept_contributor_revision as accept_contributor_revision, +) +from .reviewer_bot_builders import ( + accept_reviewer_comment as accept_reviewer_comment, +) +from .reviewer_bot_builders import ( + accept_reviewer_review as accept_reviewer_review, +) +from .reviewer_bot_builders import ( + accepted_record as accepted_record, +) +from .reviewer_bot_builders import ( + issue_snapshot as issue_snapshot, +) +from .reviewer_bot_builders import ( + make_tracked_review_state as make_tracked_review_state, +) +from .reviewer_bot_builders import ( + pull_request_payload as pull_request_payload, +) +from .reviewer_bot_builders import ( + review_payload as review_payload, +) + +__all__ = [ + "accept_contributor_comment", + "accept_contributor_revision", + "accept_reviewer_comment", + "accept_reviewer_review", + "accepted_record", + "issue_snapshot", + "make_tracked_review_state", + "pull_request_payload", + "review_payload", +] diff --git a/tests/fixtures/reviewer_bot.py b/tests/fixtures/reviewer_bot.py index 616f15e83..f91ba7779 100644 --- a/tests/fixtures/reviewer_bot.py +++ b/tests/fixtures/reviewer_bot.py @@ -4,6 +4,50 @@ from scripts import reviewer_bot +from .reviewer_bot_builders import ( + accept_contributor_comment as accept_contributor_comment, +) +from .reviewer_bot_builders import ( + accept_contributor_revision as accept_contributor_revision, +) +from .reviewer_bot_builders import ( + accept_reviewer_comment as accept_reviewer_comment, +) +from .reviewer_bot_builders import ( + accept_reviewer_review as accept_reviewer_review, +) +from .reviewer_bot_builders import ( + accepted_record as accepted_record, +) +from .reviewer_bot_builders import ( + issue_snapshot as issue_snapshot, +) +from .reviewer_bot_builders import ( + make_tracked_review_state as make_tracked_review_state, +) +from .reviewer_bot_builders import ( + pull_request_payload as pull_request_payload, +) +from .reviewer_bot_builders import ( + review_payload as review_payload, +) + +__all__ = [ + "accept_contributor_comment", + "accept_contributor_revision", + "accept_reviewer_comment", + "accept_reviewer_review", + "accepted_record", + "issue_snapshot", + "iso_z", + "make_state", + "make_tracked_review_state", + "make_zip_payload", + "pull_request_payload", + "review_payload", + "valid_reviewer_board_metadata", +] + def make_state(epoch: str | None = None): state = { diff --git a/tests/fixtures/reviewer_bot_builders.py b/tests/fixtures/reviewer_bot_builders.py new file mode 100644 index 000000000..51feb2c02 --- /dev/null +++ b/tests/fixtures/reviewer_bot_builders.py @@ -0,0 +1,164 @@ +from scripts import reviewer_bot + + +def make_tracked_review_state( + state: dict, + issue_number: int, + *, + reviewer: str | None = None, + assigned_at: str | None = None, + active_cycle_started_at: str | None = None, + repair_needed: dict | None = None, +): + review = reviewer_bot.ensure_review_entry(state, issue_number, create=True) + if review is None: + raise AssertionError(f"Unable to create review entry for #{issue_number}") + if reviewer is not None: + review["current_reviewer"] = reviewer + if assigned_at is not None: + review["assigned_at"] = assigned_at + if active_cycle_started_at is not None: + review["active_cycle_started_at"] = active_cycle_started_at + if repair_needed is not None: + review["repair_needed"] = repair_needed + return review + + +def issue_snapshot( + issue_number: int, + *, + state: str = "open", + is_pull_request: bool = False, + labels: list[dict] | list[str] | None = None, +) -> dict: + return { + "number": issue_number, + "state": state, + "pull_request": {} if is_pull_request else None, + "labels": labels or [], + } + + +def pull_request_payload( + issue_number: int, + *, + head_sha: str, + author: str = "alice", + head_repo_full_name: str | None = None, +) -> dict: + payload = { + "number": issue_number, + "state": "open", + "head": {"sha": head_sha}, + "user": {"login": author}, + } + if head_repo_full_name is not None: + payload["head"]["repo"] = {"full_name": head_repo_full_name} + return payload + + +def review_payload( + review_id: int, + *, + state: str, + submitted_at: str, + commit_id: str, + author: str, +) -> dict: + return { + "id": review_id, + "state": state, + "submitted_at": submitted_at, + "commit_id": commit_id, + "user": {"login": author}, + } + + +def accept_reviewer_comment( + review_data: dict, + *, + semantic_key: str, + timestamp: str, + actor: str, +) -> bool: + return reviewer_bot.reviews_module.accept_channel_event( + review_data, + "reviewer_comment", + semantic_key=semantic_key, + timestamp=timestamp, + actor=actor, + ) + + +def accept_reviewer_review( + review_data: dict, + *, + semantic_key: str, + timestamp: str, + actor: str, + reviewed_head_sha: str, + source_precedence: int = 1, +) -> bool: + return reviewer_bot.reviews_module.accept_channel_event( + review_data, + "reviewer_review", + semantic_key=semantic_key, + timestamp=timestamp, + actor=actor, + reviewed_head_sha=reviewed_head_sha, + source_precedence=source_precedence, + ) + + +def accept_contributor_comment( + review_data: dict, + *, + semantic_key: str, + timestamp: str, + actor: str, +) -> bool: + return reviewer_bot.reviews_module.accept_channel_event( + review_data, + "contributor_comment", + semantic_key=semantic_key, + timestamp=timestamp, + actor=actor, + ) + + +def accept_contributor_revision( + review_data: dict, + *, + semantic_key: str, + timestamp: str, + actor: str, + head_sha: str, +) -> bool: + return reviewer_bot.reviews_module.accept_channel_event( + review_data, + "contributor_revision", + semantic_key=semantic_key, + timestamp=timestamp, + actor=actor, + head_sha=head_sha, + ) + + +def accepted_record( + *, + semantic_key: str, + timestamp: str, + actor: str, + reviewed_head_sha: str | None = None, + head_sha: str | None = None, +) -> dict: + record = { + "semantic_key": semantic_key, + "timestamp": timestamp, + "actor": actor, + } + if reviewed_head_sha is not None: + record["reviewed_head_sha"] = reviewed_head_sha + if head_sha is not None: + record["head_sha"] = head_sha + return record diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 579cb9698..2ed7af174 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -1,5 +1,12 @@ from scripts import reviewer_bot -from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata +from tests.fixtures.reviewer_bot import ( + accept_reviewer_comment, + accept_reviewer_review, + issue_snapshot, + make_state, + make_tracked_review_state, + valid_reviewer_board_metadata, +) def test_reviewer_board_preflight_validates_manifest(monkeypatch): @@ -16,15 +23,17 @@ def test_reviewer_board_preflight_validates_manifest(monkeypatch): def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-20T12:34:56Z", + active_cycle_started_at="2026-03-20T12:34:56Z", + ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open"), ) preview = reviewer_bot.preview_board_projection_for_item(state, 42) @@ -38,11 +47,11 @@ def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypat def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypatch): state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) + make_tracked_review_state(state, 42) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open"), ) preview = reviewer_bot.preview_board_projection_for_item(state, 42) @@ -57,13 +66,11 @@ def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypa def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" + make_tracked_review_state(state, 42, reviewer="alice") monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "closed", "pull_request": None, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="closed"), ) preview = reviewer_bot.preview_board_projection_for_item(state, 42) @@ -80,7 +87,7 @@ def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypa monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open"), ) preview = reviewer_bot.preview_board_projection_for_item(state, 42) @@ -93,21 +100,21 @@ def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypa def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-20T12:34:56Z", + active_cycle_started_at="2026-03-20T12:34:56Z", + ) + accept_reviewer_comment( review, - "reviewer_comment", semantic_key="issue_comment:1", timestamp="2026-03-21T08:00:00Z", actor="alice", ) - reviewer_bot.reviews_module.accept_channel_event( + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:10", timestamp="2026-03-21T08:00:00Z", actor="alice", @@ -117,7 +124,7 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) monkeypatch.setattr( reviewer_bot, @@ -139,14 +146,15 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-17T09:00:00Z" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-17T09:00:00Z", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", @@ -156,7 +164,7 @@ def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state( monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) monkeypatch.setattr( reviewer_bot, @@ -194,19 +202,21 @@ def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state( def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - review["repair_needed"] = { - "kind": "projection_failure", - "reason": "projection_failed", - } + make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-20T12:34:56Z", + active_cycle_started_at="2026-03-20T12:34:56Z", + repair_needed={ + "kind": "projection_failure", + "reason": "projection_failed", + }, + ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open"), ) preview = reviewer_bot.preview_board_projection_for_item(state, 42) diff --git a/tests/unit/reviewer_bot/test_reviews_projection.py b/tests/unit/reviewer_bot/test_reviews_projection.py index c2f79812c..95928b56a 100644 --- a/tests/unit/reviewer_bot/test_reviews_projection.py +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -2,15 +2,22 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import reviews_projection -from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.reviewer_bot import ( + make_state, + make_tracked_review_state, + pull_request_payload, + review_payload, +) def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) before = json.loads(json.dumps(review)) monkeypatch.setattr( reviewer_bot, @@ -22,7 +29,7 @@ def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): "github_api_request", lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( 200, - {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" else [], + pull_request_payload(42, head_sha="head-1") if endpoint == "pulls/42" else [], {}, "ok", True, @@ -39,26 +46,28 @@ def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): def test_compute_pr_approval_state_result_is_pure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + make_state(), + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) before = json.loads(json.dumps(review)) monkeypatch.setattr( reviewer_bot, "github_api_request", lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( 200, - {"state": "open", "head": {"sha": "head-1"}} + pull_request_payload(42, head_sha="head-1") if endpoint == "pulls/42" else [ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } + review_payload( + 10, + state="APPROVED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) ], {}, "ok", From ba09bdf375785304ab83072878d22205d9604ec2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 09:12:18 +0900 Subject: [PATCH 067/209] test(reviewer-bot): add a shared GitHub transport fake --- tests/fixtures/github.py | 9 + tests/fixtures/reviewer_bot_builders.py | 2 +- tests/fixtures/reviewer_bot_fakes.py | 133 ++++ .../reviewer_bot/test_fixture_contracts.py | 79 ++ .../reviewer_bot/test_reviews_api_contract.py | 90 +-- .../reviewer_bot/test_reviews_live_fetch.py | 748 +++++++++--------- 6 files changed, 643 insertions(+), 418 deletions(-) create mode 100644 tests/fixtures/reviewer_bot_fakes.py create mode 100644 tests/unit/reviewer_bot/test_fixture_contracts.py diff --git a/tests/fixtures/github.py b/tests/fixtures/github.py index 26e17e48c..def7c01b6 100644 --- a/tests/fixtures/github.py +++ b/tests/fixtures/github.py @@ -1,3 +1,6 @@ +from .reviewer_bot_fakes import RouteGitHubApi, github_result + + class FakeGitHubResponse: def __init__(self, status_code, payload=None, text="", headers=None): self.status_code = status_code @@ -10,3 +13,9 @@ def json(self): if isinstance(self._payload, Exception): raise self._payload return self._payload + +__all__ = [ + "FakeGitHubResponse", + "RouteGitHubApi", + "github_result", +] diff --git a/tests/fixtures/reviewer_bot_builders.py b/tests/fixtures/reviewer_bot_builders.py index 51feb2c02..ce8ae348d 100644 --- a/tests/fixtures/reviewer_bot_builders.py +++ b/tests/fixtures/reviewer_bot_builders.py @@ -140,7 +140,7 @@ def accept_contributor_revision( semantic_key=semantic_key, timestamp=timestamp, actor=actor, - head_sha=head_sha, + reviewed_head_sha=head_sha, ) diff --git a/tests/fixtures/reviewer_bot_fakes.py b/tests/fixtures/reviewer_bot_fakes.py new file mode 100644 index 000000000..bd8a5edf7 --- /dev/null +++ b/tests/fixtures/reviewer_bot_fakes.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from scripts import reviewer_bot + + +def github_result( + status_code: int, + payload: Any = None, + *, + headers: dict[str, str] | None = None, + text: str | None = None, + ok: bool | None = None, + failure_kind: str | None = None, + retry_attempts: int = 0, + transport_error: str | None = None, +) -> reviewer_bot.GitHubApiResult: + normalized_headers = { + key.lower(): value for key, value in (headers or {}).items() + } + resolved_ok = status_code < 400 if ok is None else ok + resolved_failure_kind = failure_kind + if resolved_failure_kind is None and not resolved_ok: + if status_code == 404: + resolved_failure_kind = "not_found" + elif status_code == 429: + resolved_failure_kind = "rate_limited" + elif status_code is not None and status_code >= 500: + resolved_failure_kind = "server_error" + else: + resolved_failure_kind = "http_error" + resolved_text = text + if resolved_text is None: + if isinstance(payload, dict) and isinstance(payload.get("message"), str): + resolved_text = payload["message"] + elif payload is None: + resolved_text = "" + else: + resolved_text = "ok" if resolved_ok else "error" + return reviewer_bot.GitHubApiResult( + status_code=status_code, + payload=payload, + headers=normalized_headers, + text=resolved_text, + ok=resolved_ok, + failure_kind=resolved_failure_kind, + retry_attempts=retry_attempts, + transport_error=transport_error, + ) + + +@dataclass(frozen=True) +class GitHubCall: + method: str + endpoint: str + data: dict | None + extra_headers: dict[str, str] | None = None + kwargs: dict[str, Any] | None = None + + +class RouteGitHubApi: + def __init__(self): + self._request_routes: dict[tuple[str, str], reviewer_bot.GitHubApiResult] = {} + self._api_routes: dict[tuple[str, str], Any] = {} + self._raise_system_exit_on_request = False + self.request_calls: list[GitHubCall] = [] + self.api_calls: list[GitHubCall] = [] + + def add_request( + self, + method: str, + endpoint: str, + result: reviewer_bot.GitHubApiResult | None = None, + **result_kwargs, + ) -> "RouteGitHubApi": + self._request_routes[(method, endpoint)] = result or github_result(**result_kwargs) + return self + + def add_api(self, method: str, endpoint: str, payload: Any) -> "RouteGitHubApi": + self._api_routes[(method, endpoint)] = payload + return self + + def raise_system_exit_on_request(self) -> "RouteGitHubApi": + self._raise_system_exit_on_request = True + return self + + def requested_endpoints(self) -> list[str]: + return [call.endpoint for call in self.request_calls] + + def api_endpoints(self) -> list[str]: + return [call.endpoint for call in self.api_calls] + + def github_api_request( + self, + method: str, + endpoint: str, + data: dict | None = None, + extra_headers: dict[str, str] | None = None, + **kwargs, + ) -> reviewer_bot.GitHubApiResult: + self.request_calls.append( + GitHubCall( + method=method, + endpoint=endpoint, + data=data, + extra_headers=extra_headers, + kwargs=kwargs, + ) + ) + if self._raise_system_exit_on_request: + raise SystemExit(1) + route = self._request_routes.get((method, endpoint)) + if route is None: + raise AssertionError(f"Unexpected GitHub request route: {method} {endpoint}") + return route + + def github_api(self, method: str, endpoint: str, data: dict | None = None): + self.api_calls.append( + GitHubCall(method=method, endpoint=endpoint, data=data) + ) + key = (method, endpoint) + if key in self._api_routes: + return self._api_routes[key] + route = self._request_routes.get(key) + if route is None: + raise AssertionError(f"Unexpected GitHub API route: {method} {endpoint}") + if not route.ok: + return None + if route.payload is None: + return {} + return route.payload diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py new file mode 100644 index 000000000..db8307195 --- /dev/null +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -0,0 +1,79 @@ +import pytest + +from tests.fixtures.github import RouteGitHubApi, github_result + + +def test_github_result_builds_success_shape(): + result = github_result(200, {"ok": True}, headers={"ETag": "abc"}) + + assert result.status_code == 200 + assert result.payload == {"ok": True} + assert result.headers == {"etag": "abc"} + assert result.ok is True + assert result.failure_kind is None + + +def test_github_result_builds_not_found_shape(): + result = github_result(404, {"message": "missing"}) + + assert result.ok is False + assert result.failure_kind == "not_found" + assert result.text == "missing" + + +def test_github_result_builds_server_error_shape(): + result = github_result(502, {"message": "bad gateway"}) + + assert result.ok is False + assert result.failure_kind == "server_error" + assert result.text == "bad gateway" + + +def test_route_github_api_supports_invalid_payload_routes(): + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload=["not", "a", "dict"]) + + result = routes.github_api_request("GET", "pulls/42") + + assert result.ok is True + assert result.payload == ["not", "a", "dict"] + + +def test_route_github_api_raises_system_exit_on_request_and_keeps_api_mode(): + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42/reviews?per_page=100&page=1", [{"id": 10}]) + .raise_system_exit_on_request() + ) + + with pytest.raises(SystemExit): + routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") + + assert routes.github_api("GET", "pulls/42/reviews?per_page=100&page=1") == [{"id": 10}] + + +def test_route_github_api_supports_paginated_review_endpoints(): + routes = ( + RouteGitHubApi() + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[{"id": index} for index in range(100)], + ) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=2", + status_code=200, + payload=[{"id": 100}], + ) + ) + + first = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") + second = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=2") + + assert len(first.payload) == 100 + assert second.payload == [{"id": 100}] + assert routes.requested_endpoints() == [ + "pulls/42/reviews?per_page=100&page=1", + "pulls/42/reviews?per_page=100&page=2", + ] diff --git a/tests/unit/reviewer_bot/test_reviews_api_contract.py b/tests/unit/reviewer_bot/test_reviews_api_contract.py index a03c1b171..9444ecf30 100644 --- a/tests/unit/reviewer_bot/test_reviews_api_contract.py +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -1,42 +1,38 @@ import pytest from scripts import reviewer_bot +from tests.fixtures.github import RouteGitHubApi, github_result def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + routes = RouteGitHubApi().add_request( + "GET", + "issues?state=open&labels=status%3A%20awaiting%20contributor%20response&per_page=100&page=1", + result=github_result(502, {"message": "bad gateway"}, retry_attempts=1), ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) with pytest.raises(RuntimeError, match="server_error"): reviewer_bot.list_open_items_with_status_labels() def test_get_pull_request_reviews_result_paginates(monkeypatch): - responses = { - "pulls/42/reviews?per_page=100&page=1": reviewer_bot.GitHubApiResult( - 200, [{"id": i} for i in range(100)], {}, "ok", True, None, 0, None - ), - "pulls/42/reviews?per_page=100&page=2": reviewer_bot.GitHubApiResult( - 200, [{"id": 100}], {}, "ok", True, None, 0, None - ), - } - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: responses[endpoint], + routes = ( + RouteGitHubApi() + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[{"id": index} for index in range(100)], + ) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=2", + status_code=200, + payload=[{"id": 100}], + ) ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) @@ -45,8 +41,13 @@ def test_get_pull_request_reviews_result_paginates(monkeypatch): def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [{"id": 10}]) + routes = RouteGitHubApi().raise_system_exit_on_request().add_api( + "GET", + "pulls/42/reviews?per_page=100&page=1", + [{"id": 10}], + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) @@ -54,13 +55,13 @@ def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit( def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, {"not": "a list"}, {}, "ok", True, None, 0, None - ), + routes = RouteGitHubApi().add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload={"not": "a list"}, ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) @@ -68,13 +69,12 @@ def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): def test_pull_request_read_result_reports_not_found(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None - ), + routes = RouteGitHubApi().add_request( + "GET", + "pulls/42", + result=github_result(404, {"message": "missing"}), ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) @@ -82,13 +82,13 @@ def test_pull_request_read_result_reports_not_found(monkeypatch): def test_pull_request_read_result_reports_invalid_payload(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, ["not", "a", "dict"], {}, "ok", True, None, 0, None - ), + routes = RouteGitHubApi().add_request( + "GET", + "pulls/42", + status_code=200, + payload=["not", "a", "dict"], ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index d68275e8e..a73fdedfb 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -1,36 +1,54 @@ from scripts import reviewer_bot -from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.github import RouteGitHubApi, github_result +from tests.fixtures.reviewer_bot import ( + accept_contributor_comment, + accept_contributor_revision, + accept_reviewer_comment, + accept_reviewer_review, + accepted_record, + issue_snapshot, + make_state, + make_tracked_review_state, + pull_request_payload, + review_payload, +) def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) + ], + ) ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -40,49 +58,54 @@ def test_project_status_labels_uses_live_current_reviewer_review_when_channel_st def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1, ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), + ], + ) ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") monkeypatch.setattr( reviewer_bot.reviews_module, "rebuild_pr_approval_state", @@ -99,49 +122,54 @@ def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1, ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), + ], + ) ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -151,44 +179,48 @@ def test_project_status_labels_refreshes_stale_stored_review_from_live_current_h def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1, ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, - ], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:00:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), + ], + ) ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True accepted = review["reviewer_review"]["accepted"] @@ -198,16 +230,20 @@ def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_hea def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + make_state(), + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) review["reviewer_review"] = { "accepted": { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-17T10:01:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", + **accepted_record( + semantic_key="pull_request_review:10", + timestamp="2026-03-17T10:01:00Z", + actor="alice", + reviewed_head_sha="head-1", + ), "source_precedence": 1, "payload": {}, }, @@ -216,30 +252,25 @@ def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_act review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" review["transition_warning_sent"] = "2026-03-18T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } + routes = ( + RouteGitHubApi() + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) ], - {}, - "ok", - True, - None, - 0, - None, - ), + ) ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( reviewer_bot, @@ -256,20 +287,20 @@ def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_act def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_comment( review, - "reviewer_comment", semantic_key="issue_comment:1", timestamp="2026-03-17T10:00:00Z", actor="alice", ) - reviewer_bot.reviews_module.accept_channel_event( + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", @@ -279,14 +310,17 @@ def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-2")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-2")) + .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -296,50 +330,53 @@ def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch) def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1, ) - reviewer_bot.reviews_module.accept_channel_event( + accept_contributor_revision( review, - "contributor_revision", semantic_key="pull_request_sync:42:head-1", timestamp="2026-03-17T12:00:00Z", - reviewed_head_sha="head-1", - source_precedence=1, + actor="alice", + head_sha="head-1", + ) + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ) + ], + ) ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - } - ], + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) response_state = reviewer_bot.compute_reviewer_response_state(42, review) @@ -349,47 +386,52 @@ def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_r def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "vccjgust" - review["active_cycle_started_at"] = "2026-02-18T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="vccjgust", + active_cycle_started_at="2026-02-18T09:00:00Z", + ) + accept_contributor_comment( review, - "contributor_comment", semantic_key="issue_comment:20", timestamp="2026-02-18T09:30:00Z", actor="dana", ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-current"}} if endpoint == "pulls/42" else None, + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-current")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-current")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 30, + state="COMMENTED", + submitted_at="2026-02-18T10:00:00Z", + commit_id="head-older", + author="vccjgust", + ), + review_payload( + 31, + state="COMMENTED", + submitted_at="2026-02-18T11:00:00Z", + commit_id="head-current", + author="vccjgust", + ), + ], + ) ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 30, - "state": "COMMENTED", - "submitted_at": "2026-02-18T10:00:00Z", - "commit_id": "head-older", - "user": {"login": "vccjgust"}, - }, - { - "id": 31, - "state": "COMMENTED", - "submitted_at": "2026-02-18T11:00:00Z", - "commit_id": "head-current", - "user": {"login": "vccjgust"}, - }, - ], + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -399,40 +441,44 @@ def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_contributor_comment( review, - "contributor_comment", semantic_key="issue_comment:20", timestamp="2026-03-17T10:05:00Z", actor="bob", ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) + ], + ) ) monkeypatch.setattr( reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -442,20 +488,20 @@ def test_project_status_labels_prefers_newer_contributor_comment_over_live_revie def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_comment( review, - "reviewer_comment", semantic_key="issue_comment:1", timestamp="2026-03-17T10:00:00Z", actor="alice", ) - reviewer_bot.reviews_module.accept_channel_event( + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", @@ -465,43 +511,27 @@ def test_project_status_labels_emits_awaiting_write_approval_only_after_completi monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "bob"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), + ) + routes = ( + RouteGitHubApi() + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="APPROVED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="bob", + ) + ], + ) + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) @@ -515,29 +545,23 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + routes = RouteGitHubApi().add_request( + "GET", + "pulls/42", + result=github_result(502, {"message": "bad gateway"}, retry_attempts=1), ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) response_state = reviewer_bot.compute_reviewer_response_state(42, review) @@ -547,29 +571,24 @@ def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkey def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {}}, - {}, - "ok", - True, - None, - 0, - None, - ), + routes = RouteGitHubApi().add_request( + "GET", + "pulls/42", + status_code=200, + payload={"state": "open", "head": {}}, ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) response_state = reviewer_bot.compute_reviewer_response_state(42, review) @@ -579,13 +598,14 @@ def test_compute_reviewer_response_state_fails_closed_without_stored_activity_wh def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", @@ -595,43 +615,27 @@ def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypa monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - status_code=200, - payload=[ - { - "id": 10, - "state": "APPROVED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), + ) + routes = ( + RouteGitHubApi() + .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[ + review_payload( + 10, + state="APPROVED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) + ], + ) + ) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") response_state = reviewer_bot.compute_reviewer_response_state(42, review) From d951df5adbeb174d0d64af45d60ee1c9e8d04da9 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 09:31:36 +0900 Subject: [PATCH 068/209] test(reviewer-bot): add a reconcile replay harness --- scripts/reviewer_bot_lib/comment_routing.py | 27 +- tests/fixtures/reconcile_harness.py | 310 +++++ .../test_reconcile_workflow_run.py | 1180 ++++++----------- 3 files changed, 775 insertions(+), 742 deletions(-) create mode 100644 tests/fixtures/reconcile_harness.py diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 7c84e355b..3bbb99ad8 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -402,11 +402,32 @@ def _validate_accept_no_fls_changes_handoff( } -def _handle_command(bot, state: dict, request: CommentEventRequest, classified: dict) -> bool: +def _handle_command( + bot, + state: dict, + request_or_issue_number: CommentEventRequest | int, + comment_author: str | dict | None = None, + classified: dict | None = None, +) -> bool: + if isinstance(request_or_issue_number, CommentEventRequest): + request = request_or_issue_number + classified_payload = classified if classified is not None else comment_author + else: + request = CommentEventRequest( + issue_number=request_or_issue_number, + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), + comment_author=str(comment_author or ""), + comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), + ) + classified_payload = classified + if not isinstance(classified_payload, dict): + return False issue_number = request.issue_number comment_author = request.comment_author - command = classified.get("command") - args = classified.get("args") or [] + command = classified_payload.get("command") + args = classified_payload.get("args") or [] if not isinstance(command, str): return False actor_class = _classify_issue_comment_actor(request) diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py new file mode 100644 index 000000000..535ef9f75 --- /dev/null +++ b/tests/fixtures/reconcile_harness.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime + +from .reviewer_bot_builders import pull_request_payload, review_payload +from .reviewer_bot_fakes import RouteGitHubApi, github_result + + +class _ConfigBag: + def __init__(self, monkeypatch): + self.monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self.monkeypatch.setenv(name, rendered) + + +class _DeferredPayloads: + def __init__(self, payload: dict): + self.payload = payload + + def load(self) -> dict: + return self.payload + + +def review_submitted_payload( + *, + pr_number: int, + review_id: int, + source_event_key: str, + source_submitted_at: str, + source_review_state: str, + source_commit_id: str, + actor_login: str, + source_run_id: int, + source_run_attempt: int, +) -> dict: + return { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + "source_run_id": source_run_id, + "source_run_attempt": source_run_attempt, + "source_event_name": "pull_request_review", + "source_event_action": "submitted", + "source_event_key": source_event_key, + "pr_number": pr_number, + "review_id": review_id, + "source_submitted_at": source_submitted_at, + "source_review_state": source_review_state, + "source_commit_id": source_commit_id, + "actor_login": actor_login, + } + + +def issue_comment_payload( + *, + pr_number: int, + comment_id: int, + source_event_key: str, + body: str, + comment_class: str, + has_non_command_text: bool, + source_created_at: str, + actor_login: str, + source_run_id: int, + source_run_attempt: int, +) -> dict: + return { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": source_run_id, + "source_run_attempt": source_run_attempt, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": source_event_key, + "pr_number": pr_number, + "comment_id": comment_id, + "comment_class": comment_class, + "has_non_command_text": has_non_command_text, + "source_body_digest": comment_routing._digest_body(body), + "source_created_at": source_created_at, + "actor_login": actor_login, + } + + +def review_comment_payload( + *, + pr_number: int, + comment_id: int, + source_event_key: str, + body: str, + comment_class: str, + has_non_command_text: bool, + source_created_at: str, + actor_login: str, + actor_id: int, + actor_class: str, + pull_request_review_id: int, + in_reply_to_id: int, + source_run_id: int, + source_run_attempt: int, +) -> dict: + return { + "schema_version": 2, + "source_workflow_name": "Reviewer Bot PR Review Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + "source_run_id": source_run_id, + "source_run_attempt": source_run_attempt, + "source_event_name": "pull_request_review_comment", + "source_event_action": "created", + "source_event_key": source_event_key, + "pr_number": pr_number, + "comment_id": comment_id, + "comment_class": comment_class, + "has_non_command_text": has_non_command_text, + "source_body_digest": comment_routing._digest_body(body), + "source_created_at": source_created_at, + "actor_login": actor_login, + "actor_id": actor_id, + "actor_class": actor_class, + "pull_request_review_id": pull_request_review_id, + "in_reply_to_id": in_reply_to_id, + "source_artifact_name": ( + f"reviewer-bot-review-comment-context-{source_run_id}-attempt-{source_run_attempt}" + ), + } + + +@dataclass +class ReconcileHarness: + monkeypatch: object + payload: dict + + def __post_init__(self) -> None: + self.config = _ConfigBag(self.monkeypatch) + self.deferred_payloads = _DeferredPayloads(self.payload) + self.github = RouteGitHubApi() + runtime = ReviewerBotRuntime( + reviewer_bot, + config=self.config, + deferred_payloads=self.deferred_payloads, + github=self.github, + ) + self.monkeypatch.setattr(reviewer_bot, "RUNTIME", runtime) + self.set_trigger_from_payload(self.payload) + + def set_payload(self, payload: dict) -> dict: + self.payload = payload + self.deferred_payloads.payload = payload + self.set_trigger_from_payload(payload) + return payload + + def set_trigger_from_payload(self, payload: dict, *, conclusion: str = "success") -> None: + self.config.set("WORKFLOW_RUN_TRIGGERING_NAME", payload["source_workflow_name"]) + self.config.set("WORKFLOW_RUN_TRIGGERING_ID", payload["source_run_id"]) + self.config.set("WORKFLOW_RUN_TRIGGERING_ATTEMPT", payload["source_run_attempt"]) + self.config.set("WORKFLOW_RUN_TRIGGERING_CONCLUSION", conclusion) + + def add_pull_request( + self, + *, + pr_number: int, + head_sha: str | None = None, + author: str = "dana", + labels: list[str] | None = None, + requested_reviewers: list[str] | None = None, + status_code: int = 200, + ) -> None: + payload = pull_request_payload( + pr_number, + head_sha=head_sha or "", + author=author, + ) + if head_sha is None: + payload.pop("head", None) + payload["labels"] = [{"name": label} for label in (labels or [])] + if requested_reviewers is not None: + payload["requested_reviewers"] = [ + {"login": reviewer} for reviewer in requested_reviewers + ] + self.github.add_request("GET", f"pulls/{pr_number}", status_code=status_code, payload=payload) + + def add_review( + self, + *, + pr_number: int, + review_id: int, + submitted_at: str, + state: str, + commit_id: str, + author: str, + status_code: int = 200, + ) -> None: + self.github.add_request( + "GET", + f"pulls/{pr_number}/reviews/{review_id}", + status_code=status_code, + payload=review_payload( + review_id, + state=state, + submitted_at=submitted_at, + commit_id=commit_id, + author=author, + ), + ) + + def add_reviews_page(self, *, pr_number: int, reviews: list[dict], page: int = 1) -> None: + self.github.add_request( + "GET", + f"pulls/{pr_number}/reviews?per_page=100&page={page}", + status_code=200, + payload=reviews, + ) + + def add_issue_comment( + self, + *, + comment_id: int, + body: str, + author: str, + author_type: str, + author_association: str, + performed_via_github_app=None, + status_code: int = 200, + ) -> None: + self.github.add_request( + "GET", + f"issues/comments/{comment_id}", + status_code=status_code, + payload={ + "body": body, + "user": {"login": author, "type": author_type}, + "author_association": author_association, + "performed_via_github_app": performed_via_github_app, + }, + ) + + def add_review_comment( + self, + *, + comment_id: int, + body: str, + author: str, + author_type: str, + author_association: str, + performed_via_github_app=None, + status_code: int = 200, + ) -> None: + self.github.add_request( + "GET", + f"pulls/comments/{comment_id}", + status_code=status_code, + payload={ + "body": body, + "user": {"login": author, "type": author_type}, + "author_association": author_association, + "performed_via_github_app": performed_via_github_app, + }, + ) + + def add_request_failure( + self, + *, + endpoint: str, + status_code: int, + payload: dict, + retry_attempts: int = 1, + failure_kind: str | None = None, + ) -> None: + self.github.add_request( + "GET", + endpoint, + result=github_result( + status_code, + payload, + retry_attempts=retry_attempts, + failure_kind=failure_kind, + ), + ) + + def stub_head_repair(self, *, changed: bool = False, outcome: str = "unchanged") -> None: + self.monkeypatch.setattr( + reviewer_bot, + "maybe_record_head_observation_repair", + lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=changed, + outcome=outcome, + ), + ) + + def stub_review_rebuild(self, *, changed: bool = False) -> None: + self.monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_record_review_rebuild", + lambda bot, state_obj, issue_number, review_data: changed, + ) + + def run(self, state: dict) -> bool: + return reviewer_bot.handle_workflow_run_event(state) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 4d64cecbf..e07094a1c 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -1,605 +1,412 @@ -import json -import os import pytest +from scripts import reviewer_bot +from tests.fixtures.reconcile_harness import ( + ReconcileHarness, + issue_comment_payload, + review_comment_payload, + review_submitted_payload, +) +from tests.fixtures.reviewer_bot import ( + accept_reviewer_review, + make_state, + make_tracked_review_state, + review_payload, +) + pytestmark = pytest.mark.integration -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing -from tests.fixtures.reviewer_bot import make_state def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations( - tmp_path, monkeypatch + monkeypatch, ): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "bob" + review = make_tracked_review_state(state, 42, reviewer="bob") review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } + harness = ReconcileHarness( + monkeypatch, + review_submitted_payload( + pr_number=42, + review_id=11, + source_event_key="pull_request_review:11", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, + harness.stub_review_rebuild(changed=False) + harness.stub_head_repair(changed=False) + harness.add_pull_request(pr_number=42, head_sha="head-1", author="dana") + harness.add_review( + pr_number=42, + review_id=11, + submitted_at="2026-03-17T10:00:00Z", + state="COMMENTED", + commit_id="head-1", + author="alice", ) - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/11": - return { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - raise AssertionError(endpoint) - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert "pull_request_review:11" in review["reconciled_source_events"] assert "pull_request_review:11" not in review["deferred_gaps"] -def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(tmp_path, monkeypatch): + +def test_handle_workflow_run_event_persists_fail_closed_diagnostic_without_raising(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:12", - "pr_number": 42, - "review_id": 12, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } + review = make_tracked_review_state(state, 42, reviewer="alice") + harness = ReconcileHarness( + monkeypatch, + review_submitted_payload( + pr_number=42, + review_id=12, + source_event_key="pull_request_review:12", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=501, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "pulls/42/reviews/12": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + ) + harness.add_pull_request(pr_number=42, head_sha="head-1", author="dana") + harness.add_request_failure( + endpoint="pulls/42/reviews/12", + status_code=502, + payload={"message": "bad gateway"}, + failure_kind="server_error", + ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True gap = review["deferred_gaps"]["pull_request_review:12"] assert gap["reason"] == "reconcile_failed_closed" assert gap["failure_kind"] == "server_error" -def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" + review = make_tracked_review_state(state, 42, reviewer="alice") review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-command.json" live_body = "@guidelines-bot /queue" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 610, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:210", - "pr_number": 42, - "comment_id": 210, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=210, + source_event_key="issue_comment:210", + body=live_body, + comment_class="command_only", + has_non_command_text=False, + source_created_at="2026-03-17T10:00:00Z", + actor_login="bob", + source_run_id=610, + source_run_attempt=1, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "610") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + harness.add_pull_request(pr_number=42, author="dana", labels=["coding guideline"]) + harness.add_issue_comment( + comment_id=210, + body=live_body, + author="bob", + author_type="User", + author_association="MEMBER", + ) monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/210": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert "issue_comment:210" in review["reconciled_source_events"] assert "issue_comment:210" not in review["deferred_gaps"] -def test_deferred_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + +def test_deferred_comment_missing_live_object_preserves_source_time_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 501, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:99", - "pr_number": 42, - "comment_id": 99, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - } + make_tracked_review_state(state, 42, reviewer="alice") + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=99, + source_event_key="issue_comment:99", + body="stale body", + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="alice", + source_run_id=501, + source_run_attempt=1, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "501") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), + harness.add_pull_request(pr_number=42, author="dana") + harness.add_request_failure( + endpoint="issues/comments/99", + status_code=404, + payload={"message": "missing"}, + failure_kind="not_found", ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"]["semantic_key"] == "issue_comment:99" assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:99"]["reason"] == "reconcile_failed_closed" -def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(tmp_path, monkeypatch): + +def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_id(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - payload_path = tmp_path / "deferred.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "APPROVED", - "source_commit_id": "head-1", - "actor_login": "alice", - } + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + harness = ReconcileHarness( + monkeypatch, + review_submitted_payload( + pr_number=42, + review_id=11, + source_event_key="pull_request_review:11", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="APPROVED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), + harness.add_pull_request(pr_number=42, head_sha="head-2", author="dana") + harness.add_review( + pr_number=42, + review_id=11, + submitted_at="2026-03-17T10:00:00Z", + state="APPROVED", + commit_id="head-1", + author="alice", ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "APPROVED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } + harness.add_reviews_page( + pr_number=42, + reviews=[ + review_payload( + 11, + state="APPROVED", + submitted_at="2026-03-17T10:00:00Z", + commit_id="head-1", + author="alice", + ) ], ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + + assert harness.run(state) is True + assert review["current_cycle_completion"]["completed"] is False - assert reviewer_bot.handle_workflow_run_event(state) is True - assert state["active_reviews"]["42"]["current_cycle_completion"]["completed"] is False def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_review( - tmp_path, monkeypatch + monkeypatch, ): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1, ) - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:99", - "pr_number": 42, - "review_id": 99, - "source_submitted_at": "2026-03-17T11:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-0", - "actor_login": "alice", - } + harness = ReconcileHarness( + monkeypatch, + review_submitted_payload( + pr_number=42, + review_id=99, + source_event_key="pull_request_review:99", + source_submitted_at="2026-03-17T11:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-0", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/99": - return { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "submitted_at": "2026-03-17T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, + ) + harness.add_pull_request(pr_number=42, head_sha="head-1", author="dana") + harness.add_review( + pr_number=42, + review_id=99, + submitted_at="2026-03-17T11:00:00Z", + state="COMMENTED", + commit_id="head-0", + author="alice", + ) + harness.add_reviews_page( + pr_number=42, + reviews=[ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:00:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), ], ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True accepted = review["reviewer_review"]["accepted"] assert accepted["semantic_key"] == "pull_request_review:10" assert accepted["reviewed_head_sha"] == "head-1" -def test_deferred_review_comment_reconcile_records_contributor_freshness(tmp_path, monkeypatch): + +def test_deferred_review_comment_reconcile_records_contributor_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "author reply in review thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 701, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:301", - "pr_number": 42, - "comment_id": 301, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - "actor_id": 5, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-701-attempt-1", - } + harness = ReconcileHarness( + monkeypatch, + review_comment_payload( + pr_number=42, + comment_id=301, + source_event_key="pull_request_review_comment:301", + body=live_body, + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="dana", + actor_id=5, + actor_class="repo_user_principal", + pull_request_review_id=10, + in_reply_to_id=200, + source_run_id=701, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "701") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/301": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + ) + harness.add_pull_request(pr_number=42, author="dana") + harness.add_review_comment( + comment_id=301, + body=live_body, + author="dana", + author_type="User", + author_association="CONTRIBUTOR", + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted is not None assert accepted["semantic_key"] == "pull_request_review_comment:301" -def test_deferred_review_comment_reconcile_records_reviewer_freshness(tmp_path, monkeypatch): + +def test_deferred_review_comment_reconcile_records_reviewer_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" + review = make_tracked_review_state(state, 42, reviewer="alice") live_body = "reviewer reply in thread" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 702, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:302", - "pr_number": 42, - "comment_id": 302, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T11:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-702-attempt-1", - } + harness = ReconcileHarness( + monkeypatch, + review_comment_payload( + pr_number=42, + comment_id=302, + source_event_key="pull_request_review_comment:302", + body=live_body, + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T11:00:00Z", + actor_login="alice", + actor_id=6, + actor_class="repo_user_principal", + pull_request_review_id=10, + in_reply_to_id=200, + source_run_id=702, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "702") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/302": - return { - "body": live_body, - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + ) + harness.add_pull_request(pr_number=42, author="dana") + harness.add_review_comment( + comment_id=302, + body=live_body, + author="alice", + author_type="User", + author_association="MEMBER", + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" -def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(tmp_path, monkeypatch): + +def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 703, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:303", - "pr_number": 42, - "comment_id": 303, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-703-attempt-1", - } + review = make_tracked_review_state(state, 42, reviewer="alice") + harness = ReconcileHarness( + monkeypatch, + review_comment_payload( + pr_number=42, + comment_id=303, + source_event_key="pull_request_review_comment:303", + body="stale body", + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="alice", + actor_id=6, + actor_class="repo_user_principal", + pull_request_review_id=10, + in_reply_to_id=200, + source_run_id=703, + source_run_attempt=1, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "703") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: ({"user": {"login": "dana"}, "labels": []} if endpoint == "pulls/42" else None), + harness.add_pull_request(pr_number=42, author="dana") + harness.add_request_failure( + endpoint="pulls/comments/303", + status_code=404, + payload={"message": "missing"}, + failure_kind="not_found", ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" assert review["deferred_gaps"]["pull_request_review_comment:303"]["reason"] == "reconcile_failed_closed" -def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguous(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:201", - "pr_number": 42, - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=201, + source_event_key="issue_comment:201", + body=live_body, + comment_class="command_only", + has_non_command_text=False, + source_created_at="2026-03-17T10:00:00Z", + actor_login="bob", + source_run_id=603, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/201": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + ) + harness.add_pull_request(pr_number=42, author="dana") + harness.add_issue_comment( + comment_id=201, + body=live_body, + author="bob", + author_type="User", + author_association="MEMBER", + ) monkeypatch.setattr( reviewer_bot.reconcile_module, "classify_comment_payload", @@ -619,149 +426,100 @@ def fake_github_api(method, endpoint, data=None): lambda *args, **kwargs: command_calls.append("called") or True, ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert command_calls == [] assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]["reason"] == "reconcile_failed_closed" assert "issue_comment:201" not in state["active_reviews"]["42"]["reconciled_source_events"] -def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_freshness(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 601, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:199", - "pr_number": 42, - "comment_id": 199, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=199, + source_event_key="issue_comment:199", + body=live_body, + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="dana", + source_run_id=601, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "601") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/199": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + ) + harness.add_pull_request(pr_number=42, author="dana", labels=["coding guideline"]) + harness.add_issue_comment( + comment_id=199, + body=live_body, + author="dana", + author_type="User", + author_association="CONTRIBUTOR", + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None - assert os.environ["IS_PULL_REQUEST"] == "true" - assert os.environ["ISSUE_AUTHOR"] == "dana" - assert json.loads(os.environ["ISSUE_LABELS"]) == ["coding guideline"] + assert harness.config.values["IS_PULL_REQUEST"] == "true" + assert harness.config.values["ISSUE_AUTHOR"] == "dana" + assert harness.config.values["ISSUE_LABELS"] == '["coding guideline"]' -def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monkeypatch): state = make_state() state["queue"] = [{"github": "bob", "name": "Bob"}] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-command.json" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "@guidelines-bot /claim" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 602, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:200", - "pr_number": 42, - "comment_id": 200, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=200, + source_event_key="issue_comment:200", + body=live_body, + comment_class="command_only", + has_non_command_text=False, + source_created_at="2026-03-17T10:00:00Z", + actor_login="bob", + source_run_id=602, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "602") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - assignment_calls = [] - removed_reviewers = [] - posted_comments = [] - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return { - "user": {"login": "dana"}, - "labels": [{"name": "coding guideline"}], - "requested_reviewers": [{"login": "alice"}], - } - if endpoint == "issues/comments/200": - return { - "body": live_body, - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - def fake_request(issue_number, username): - assignment_calls.append( + ) + harness.add_pull_request( + pr_number=42, + author="dana", + labels=["coding guideline"], + requested_reviewers=["alice"], + ) + harness.add_issue_comment( + comment_id=200, + body=live_body, + author="bob", + author_type="User", + author_association="MEMBER", + ) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + claim_contexts = [] + monkeypatch.setattr( + reviewer_bot.reconcile_module, + "_handle_command", + lambda bot, state_obj, issue_number, comment_author, classified: claim_contexts.append( { "issue_number": issue_number, - "username": username, - "is_pull_request": os.environ.get("IS_PULL_REQUEST"), - "issue_author": os.environ.get("ISSUE_AUTHOR"), + "username": comment_author, + "is_pull_request": harness.config.values.get("IS_PULL_REQUEST"), + "issue_author": harness.config.values.get("ISSUE_AUTHOR"), } - ) - return reviewer_bot.AssignmentAttempt(success=True, status_code=201) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "request_reviewer_assignment", fake_request) - monkeypatch.setattr( - reviewer_bot, - "unassign_reviewer", - lambda issue_number, username: removed_reviewers.append((issue_number, username)) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "post_comment", - lambda issue_number, body: posted_comments.append((issue_number, body)) or True, + ) or state_obj["active_reviews"][str(issue_number)].__setitem__("current_reviewer", comment_author) or True, ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - assert reviewer_bot.handle_workflow_run_event(state) is True - assert assignment_calls == [ + assert harness.run(state) is True + assert claim_contexts == [ { "issue_number": 42, "username": "bob", @@ -769,125 +527,69 @@ def fake_request(issue_number, username): "issue_author": "dana", } ] - assert removed_reviewers == [(42, "alice")] assert state["active_reviews"]["42"]["current_reviewer"] == "bob" - assert posted_comments -def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment-unavailable.json" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 603, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:205", - "pr_number": 42, - "comment_id": 205, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=205, + source_event_key="issue_comment:205", + body=live_body, + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="dana", + source_run_id=603, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "603") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - status_code=200, - payload={"user": {"login": "dana"}, "labels": []}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) - if endpoint == "issues/comments/205": - return reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ) - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + ) + harness.add_pull_request(pr_number=42, author="dana") + harness.add_request_failure( + endpoint="issues/comments/205", + status_code=502, + payload={"message": "bad gateway"}, + failure_kind="server_error", + ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True gap = state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:205"] assert gap["reason"] == "reconcile_failed_closed" assert gap["failure_kind"] == "server_error" -def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(tmp_path, monkeypatch): + +def test_deferred_comment_reconcile_fails_closed_when_comment_classification_drifts(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-comment.json" + make_tracked_review_state(state, 42, reviewer="alice") live_body = "reviewer-bot validation: contributor plain text comment" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 604, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:202", - "pr_number": 42, - "comment_id": 202, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": comment_routing._digest_body(live_body), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "dana", - } + harness = ReconcileHarness( + monkeypatch, + issue_comment_payload( + pr_number=42, + comment_id=202, + source_event_key="issue_comment:202", + body=live_body, + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="dana", + source_run_id=604, + source_run_attempt=1, ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "604") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/202": - return { - "body": live_body, - "user": {"login": "dana", "type": "User"}, - "author_association": "CONTRIBUTOR", - "performed_via_github_app": None, - } - raise AssertionError(f"Unexpected endpoint: {endpoint}") - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) + ) + harness.add_pull_request(pr_number=42, author="dana") + harness.add_issue_comment( + comment_id=202, + body=live_body, + author="dana", + author_type="User", + author_association="CONTRIBUTOR", + ) monkeypatch.setattr( reviewer_bot.reconcile_module, "classify_comment_payload", @@ -901,7 +603,7 @@ def fake_github_api(method, endpoint, data=None): }, ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:202" assert state["active_reviews"]["42"]["deferred_gaps"]["issue_comment:202"]["reason"] == "reconcile_failed_closed" assert "issue_comment:202" not in state["active_reviews"]["42"]["reconciled_source_events"] From 28f5beb2013c09d6a269ec3623d2deb892d217d8 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 11:16:32 +0900 Subject: [PATCH 069/209] test(reviewer-bot): add an app CLI harness --- tests/fixtures/app_harness.py | 69 + .../reviewer_bot/test_cli_entrypoint.py | 1744 +---------------- 2 files changed, 123 insertions(+), 1690 deletions(-) create mode 100644 tests/fixtures/app_harness.py diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py new file mode 100644 index 000000000..180710691 --- /dev/null +++ b/tests/fixtures/app_harness.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime + + +class _ConfigBag: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self._monkeypatch.setenv(name, rendered) + + +class _OutputCapture: + def __init__(self): + self.writes: list[tuple[str, str]] = [] + + def write(self, name: str, value: str) -> None: + self.writes.append((name, value)) + + +@dataclass +class MainRun: + exit_code: int | None + context: reviewer_bot.EventContext | None = None + + +class AppHarness: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.config = _ConfigBag(monkeypatch) + self.outputs = _OutputCapture() + runtime = ReviewerBotRuntime( + reviewer_bot, + config=self.config, + outputs=self.outputs, + ) + self._monkeypatch.setattr(reviewer_bot, "RUNTIME", runtime) + + def set_event(self, **values) -> None: + for name, value in values.items(): + self.config.set(name, value) + + def stub_execute_run(self, result: reviewer_bot.ExecutionResult) -> MainRun: + captured = MainRun(exit_code=None) + + def fake_execute_run(bot, context): + captured.context = context + return result + + self._monkeypatch.setattr(reviewer_bot.app_module, "execute_run", fake_execute_run) + return captured + + def run_main(self) -> MainRun: + try: + reviewer_bot.main() + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 + return MainRun(exit_code=code) + return MainRun(exit_code=None) diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index c06124551..a6df93048 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -1,1720 +1,84 @@ -import json import pytest -pytestmark = pytest.mark.integration - from scripts import reviewer_bot -from scripts.reviewer_bot_lib.lifecycle import HeadObservationRepairResult +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state -from tests.fixtures.reviewer_bot import ( - valid_reviewer_board_metadata as _valid_reviewer_board_metadata, -) - -def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "show-state") - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - - reviewer_bot.main() - - output = capsys.readouterr().out - assert "Current state:" in output - assert "freshness_runtime_epoch" in output - -def test_classify_event_intent_cross_repo_review_is_non_mutating_defer(monkeypatch): - monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") - intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - -def test_classify_event_intent_preview_reviewer_board_is_non_mutating(monkeypatch): - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - intent = reviewer_bot.classify_event_intent("workflow_dispatch", "") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY - -def test_classify_event_intent_same_repo_review_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - -def test_classify_event_intent_same_repo_dismissed_review_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review", "dismissed") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - -def test_classify_event_intent_review_comment_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review_comment", "created") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - -def test_classify_event_intent_workflow_run_dismissed_review_is_mutating(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "dismissed") - intent = reviewer_bot.classify_event_intent("workflow_run", "completed") - assert intent == reviewer_bot.EVENT_INTENT_MUTATING - -def test_main_cross_repo_review_does_not_acquire_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "pull_request_review") - monkeypatch.setenv("EVENT_ACTION", "submitted") - monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") - - acquire_called = {"value": False} - - def fail_if_called(): - acquire_called["value"] = True - raise AssertionError("acquire_state_issue_lease_lock should not be called") - - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) - - reviewer_bot.main() - - assert acquire_called["value"] is False - -def test_main_same_repo_review_does_not_acquire_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "pull_request_review") - monkeypatch.setenv("EVENT_ACTION", "submitted") - - acquire_called = {"value": False} - - def fail_if_called(): - acquire_called["value"] = True - raise AssertionError("acquire_state_issue_lease_lock should not be called") - - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) - - reviewer_bot.main() - - assert acquire_called["value"] is False - -def test_main_workflow_run_reconcile_acquires_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - - acquire_called = {"value": False} - - def fake_acquire(): - acquire_called["value"] = True - return reviewer_bot.LeaseContext( - lock_token="token", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) - - reviewer_bot.main() - assert acquire_called["value"] is True - -def test_main_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - - acquire_called = {"value": False} - - def fake_acquire(): - acquire_called["value"] = True - return reviewer_bot.LeaseContext( - lock_token="token", - lock_owner_run_id="run", - lock_owner_workflow="workflow", - lock_owner_job="job", - state_issue_url="https://example.com/issues/314", - lock_ref="refs/heads/reviewer-bot-state-lock", - lock_expires_at="2999-01-01T00:00:00+00:00", - ) - - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) - - reviewer_bot.main() - - assert acquire_called["value"] is True - -def test_main_reloads_state_before_syncing_status_labels(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - - initial_state = make_state() - reloaded_state = make_state() - load_calls = {"count": 0} - call_order = [] - - def fake_load_state(*, fail_on_unavailable=False): - load_calls["count"] += 1 - call_order.append(f"load:{load_calls['count']}") - if load_calls["count"] == 1: - return initial_state - return reloaded_state - - def fake_handle_comment_event(state): - assert state is initial_state - reviewer_bot.collect_touched_item(42) - call_order.append("handle") - return True - - def fake_save_state(state): - assert state is initial_state - call_order.append("save") - return True - - def fake_sync_status_labels_for_items(state, issue_numbers): - call_order.append("sync") - assert state is reloaded_state - assert list(issue_numbers) == [42] - return True - - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", fake_handle_comment_event) - monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", fake_sync_status_labels_for_items) - - reviewer_bot.main() - - assert call_order == [ - "load:1", - "handle", - "load:2", - "save", - "load:3", - "load:4", - "sync", - ] - -def test_main_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "false") - - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), - ) - - reviewer_bot.main() - - output = capsys.readouterr().out - assert "Reviewer board preview skipped: reviewer board is disabled." in output +pytestmark = pytest.mark.integration -def test_main_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), +def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="show-state", ) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err - -def test_main_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "github_graphql", - lambda query, variables=None, *, token=None: { - "data": { - "organization": { - "projectV2": { - "id": "PVT_kwDOB", - "title": "Reviewer Board", - "fields": {"nodes": []}, - } - } - } - }, - ) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - assert "Missing reviewer board field: Review State" in capsys.readouterr().err - -def test_main_preview_reviewer_board_is_read_only(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - - 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" - review["assigned_at"] = "2026-03-20T12:34:56Z" - review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), - ) - monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: _valid_reviewer_board_metadata()) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) - reviewer_bot.main() + run = harness.run_main() + assert run.exit_code is None output = capsys.readouterr().out - assert "classification: open_tracked_assigned" in output - assert "ensure_membership: true" in output - -def test_issue_close_then_close_comment_does_not_leave_active_review(monkeypatch): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_TITLE", "Validation issue") - monkeypatch.setenv("ISSUE_BODY", "body") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") - - assert reviewer_bot.handle_closed_event(state) is True - assert "42" not in state["active_reviews"] - assert reviewer_bot.handle_comment_event(state) is False - assert "42" not in state["active_reviews"] - -def test_main_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") - - initial_state = make_state() - review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - reloaded_state = make_state() - load_calls = {"count": 0} - save_calls = [] - sync_calls = [] - - def fake_load_state(*, fail_on_unavailable=False): - load_calls["count"] += 1 - if load_calls["count"] == 1: - return initial_state - return reloaded_state - - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda state: save_calls.append("42" in state["active_reviews"]) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True, - ) - - reviewer_bot.main() - - assert save_calls == [False] - assert len(sync_calls) == 1 - assert sync_calls[0][0] is reloaded_state - assert sync_calls[0][1] == [42] - -def test_main_closed_issue_comment_without_entry_skips_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") - - state = make_state() - save_called = {"value": False} - sync_calls = [] - - 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, - "save_state", - lambda current: save_called.__setitem__("value", True) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False, - ) - - reviewer_bot.main() - - assert save_called["value"] is False - assert sync_calls == [[42]] - -def test_main_label_signoff_issue_saves_before_status_projection(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") - - initial_state = make_state() - review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - reloaded_state = make_state() - load_calls = {"count": 0} - call_order = [] - - def fake_load_state(*, fail_on_unavailable=False): - load_calls["count"] += 1 - if load_calls["count"] == 1: - return initial_state - return reloaded_state - - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: call_order.append("save") or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: call_order.append("sync") or current is reloaded_state, - ) - - reviewer_bot.main() - - assert initial_state["active_reviews"]["42"]["review_completion_source"] == "issue_label: sign-off: create pr" - assert call_order == ["save", "sync"] - -def test_main_label_signoff_issue_does_not_project_labels_when_save_fails(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") - - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - 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, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "save_state", lambda current: False) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: (_ for _ in ()).throw( - AssertionError("status projection must not run after failed save") - ), - ) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - -def test_main_fails_when_save_state_fails(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - 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: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", lambda state: True) - monkeypatch.setattr(reviewer_bot, "save_state", lambda state: False) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - -def test_main_workflow_run_fails_closed_on_invalid_context(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") - 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: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr( - reviewer_bot, - "handle_workflow_run_event", - lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context")), - ) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - -def test_main_schedule_backfills_existing_transition_notice_without_duplicate_comment(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - review["transition_warning_sent"] = "2026-03-10T00:00:00Z" - - 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "check_overdue_reviews", - lambda bot, current: [ - { - "issue_number": 42, - "reviewer": "alice", - "days_overdue": 20, - "days_since_warning": 15, - "needs_warning": False, - "needs_transition": True, - } - ], - ) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}) - save_calls = [] - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_calls.append(current["active_reviews"]["42"]["transition_notice_sent_at"]) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - - def fake_api(method, endpoint, data=None): - if endpoint == "issues/42/comments?per_page=100": - return [{"id": 99, "created_at": "2026-03-25T15:22:42Z", "body": "🔔 **Transition Period Ended**\n\nExisting notice", "user": {"login": "github-actions[bot]"}}] - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_api) - reviewer_bot.main() - assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" - assert posted == [] - assert save_calls == ["2026-03-25T15:22:42Z"] - -def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - - 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - save_calls = [] - monkeypatch.setattr(reviewer_bot, "save_state", lambda current: save_calls.append(current) or True) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, - ) - - reviewer_bot.main() - - assert review["reviewer_review"]["accepted"]["semantic_key"] == "pull_request_review:10" - assert synced_issue_numbers == [42] - assert len(save_calls) == 1 - -def test_main_schedule_structured_head_repair_result_without_mutation_skips_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - save_called = {"value": False} - - 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_called.__setitem__("value", True) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("no touched items expected")), - ) - - reviewer_bot.main() - - assert save_called["value"] is False - -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") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - - def fail_load(*, fail_on_unavailable=False): - assert fail_on_unavailable is True - raise RuntimeError("state unavailable") - - monkeypatch.setattr(reviewer_bot, "load_state", fail_load) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - -def test_main_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - - called = { - "pass_until": False, - "sync": False, - "handler": False, - "save": False, - } - - def fail_load(*, fail_on_unavailable=False): - assert fail_on_unavailable is True - raise RuntimeError("state unavailable") - - def track_pass_until(state): - called["pass_until"] = True - return state, [] - - def track_sync(state): - called["sync"] = True - return state, [] - - def track_handler(state): - called["handler"] = True - return True - - def track_save(state): - called["save"] = True - return True - - monkeypatch.setattr(reviewer_bot, "load_state", fail_load) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", track_pass_until) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", track_sync) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", track_handler) - monkeypatch.setattr(reviewer_bot, "save_state", track_save) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - assert called == { - "pass_until": False, - "sync": False, - "handler": False, - "save": False, - } - -def test_schedule_guard_blocks_empty_active_reviews_wipe(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - - state = make_state() - state["active_reviews"] = { - "42": { - "current_reviewer": "alice", - "assigned_at": "2026-01-01T00:00:00+00:00", - "last_reviewer_activity": "2026-01-01T00:00:00+00:00", - } - } - - def wipe_active_reviews(input_state): - input_state["active_reviews"] = {} - return True - - save_called = {"value": False} - - def track_save(_state): - save_called["value"] = True - return 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", wipe_active_reviews) - monkeypatch.setattr(reviewer_bot, "save_state", track_save) - - with pytest.raises(SystemExit) as excinfo: - reviewer_bot.main() - - assert excinfo.value.code == 1 - assert save_called["value"] is False - -def test_main_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") - - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "bob" - review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} - - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - __import__("json").dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 700, - "source_run_attempt": 1, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "700") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - save_snapshots = [] - 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.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/11": - return { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_snapshots.append( - { - "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), - "gap_present": "pull_request_review:11" in current["active_reviews"]["42"]["deferred_gaps"], - } - ) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, - ) - - reviewer_bot.main() - - assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] - assert synced_issue_numbers == [42] - -def test_main_pull_request_synchronize_head_only_mutation_still_saves_state(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "pull_request_target") - monkeypatch.setenv("EVENT_ACTION", "synchronize") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("PR_HEAD_SHA", "head-2") - monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") - - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_head_sha"] = "head-1" - review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] - - save_calls = [] - - 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.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_calls.append(current["active_reviews"]["42"]["active_head_sha"]) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - - reviewer_bot.main() - - assert save_calls == ["head-2"] - -def test_main_schedule_identical_live_read_failure_does_not_repeat_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - save_snapshots = [] - - 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="skipped_unavailable", - failure_kind="server_error", - reason="pull_request_unavailable", - ), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_snapshots.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - - reviewer_bot.main() - reviewer_bot.main() - - assert len(save_snapshots) == 1 - assert save_snapshots[0]["kind"] == "live_read_failure" - -def test_main_identical_projection_failure_does_not_repeat_repair_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - save_markers = [] - - 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_comment_event", - lambda current: reviewer_bot.collect_touched_item(42) or False, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_markers.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed")), - ) - - reviewer_bot.main() - reviewer_bot.main() - - assert len(save_markers) == 1 - assert save_markers[0]["kind"] == "projection_failure" - -def test_main_changed_projection_failure_reason_triggers_new_repair_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - save_markers = [] - failure_messages = iter(["projection failed", "projection still failed differently"]) - - 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_comment_event", - lambda current: reviewer_bot.collect_touched_item(42) or False, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_markers.append(dict(current["active_reviews"]["42"]["repair_needed"])) or True, - ) - - def fail_projection(current, issue_numbers): - raise RuntimeError(next(failure_messages)) - - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", fail_projection) - - reviewer_bot.main() - reviewer_bot.main() - - assert len(save_markers) == 2 - assert save_markers[0]["reason"] == "projection failed" - assert save_markers[1]["reason"] == "projection still failed differently" - -def test_main_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( - tmp_path, monkeypatch -): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") - - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} - - payload_path = tmp_path / "deferred-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 710, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:210", - "pr_number": 42, - "comment_id": 210, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /queue"), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "710") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - save_snapshots = [] - - 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.reconcile_module, "_handle_command", lambda *args, **kwargs: False) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/210": - return { - "body": "@guidelines-bot /queue", - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_snapshots.append( - { - "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), - "gap_present": "issue_comment:210" in current["active_reviews"]["42"]["deferred_gaps"], - } - ) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - - reviewer_bot.main() - - assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] - -def test_main_workflow_run_deferred_comment_contract_drift_persists_gap_update( - tmp_path, monkeypatch -): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") - - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - - payload_path = tmp_path / "deferred-command.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 712, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:201", - "pr_number": 42, - "comment_id": 201, - "comment_class": "command_only", - "has_non_command_text": False, - "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /claim"), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "bob", - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "712") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - save_snapshots = [] - - 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, [])) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "issues/comments/201": - return { - "body": "@guidelines-bot /claim", - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "classify_comment_payload", - lambda bot, body: { - "comment_class": "command_only", - "has_non_command_text": False, - "command_count": 2, - "command": None, - "args": [], - "normalized_body": body, - }, - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_snapshots.append( - dict(current["active_reviews"]["42"]["deferred_gaps"]["issue_comment:201"]) - ) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - - reviewer_bot.main() - - assert save_snapshots[0]["reason"] == "reconcile_failed_closed" - -def test_main_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( - tmp_path, monkeypatch -): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + assert "Current state:" in output + assert "freshness_runtime_epoch" in output - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["deferred_gaps"]["pull_request_review_comment:310"] = {"reason": "artifact_missing"} - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 711, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:310", - "pr_number": 42, - "comment_id": 310, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": reviewer_bot.comment_routing_module._digest_body("review comment body"), - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - } - ), - encoding="utf-8", +def test_main_builds_event_context_for_preview_wrapper(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "711") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - - save_snapshots = [] - - 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, [])) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/310": - return { - "body": "review comment body", - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - "created_at": "2026-03-17T10:00:00Z", - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_snapshots.append( - { - "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), - "gap_present": "pull_request_review_comment:310" - in current["active_reviews"]["42"]["deferred_gaps"], - } - ) or True, + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=0, state_changed=False) ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - reviewer_bot.main() + run = harness.run_main() - assert save_snapshots == [ - {"reconciled": ["pull_request_review_comment:310"], "gap_present": False} - ] + assert run.exit_code is None + assert captured.context is not None + assert captured.context.event_name == "workflow_dispatch" + assert captured.context.event_action == "" + assert captured.context.manual_action == "preview-reviewer-board" -def test_main_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - save_calls = [] - - def fake_sweep(bot, current): - current["active_reviews"]["42"].setdefault("reconciled_source_events", []).append( - "pull_request_review:500" - ) - return True - - 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.maintenance_module, "sweep_deferred_gaps", fake_sweep) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), +def test_main_builds_workflow_run_context_before_execution(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="pull_request_review", + WORKFLOW_RUN_EVENT_ACTION="dismissed", ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_calls.append( - list(current["active_reviews"]["42"]["reconciled_source_events"]) - ) or True, + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=0, state_changed=False) ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - reviewer_bot.main() + run = harness.run_main() - assert save_calls == [["pull_request_review:500"]] + assert run.exit_code is None + assert captured.context is not None + assert captured.context.event_name == "workflow_run" + assert captured.context.event_action == "completed" + assert captured.context.workflow_run_event == "pull_request_review" + assert captured.context.workflow_run_event_action == "dismissed" -def test_main_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - review["reviewer_review"] = { - "accepted": { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-17T10:01:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - }, - "seen_keys": ["pull_request_review:10"], - } - review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" - review["transition_warning_sent"] = "2026-03-18T00:00:00Z" - review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - save_calls = [] - - def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): - if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}}, - {}, - "ok", - True, - None, - 0, - None, - ) - if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( - 200, - [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ) - raise AssertionError(endpoint) - - 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.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, +def test_main_exits_with_nonzero_execution_result(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", ) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=1, state_changed=False) ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_calls.append( - { - "last_reviewer_activity": current["active_reviews"]["42"]["last_reviewer_activity"], - "transition_warning_sent": current["active_reviews"]["42"]["transition_warning_sent"], - "transition_notice_sent_at": current["active_reviews"]["42"]["transition_notice_sent_at"], - } - ) or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - - reviewer_bot.main() - - assert save_calls == [ - { - "last_reviewer_activity": "2026-03-17T10:01:00Z", - "transition_warning_sent": None, - "transition_notice_sent_at": None, - } - ] - -def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING - -def test_main_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): - state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "plain text") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - saved_states = [] - - def fake_load_state(*, fail_on_unavailable=False): - return json.loads(json.dumps(state)) - def fake_save_state(updated_state): - saved_states.append(json.loads(json.dumps(updated_state))) - state.clear() - state.update(json.loads(json.dumps(updated_state))) - return True + run = harness.run_main() - monkeypatch.setattr(reviewer_bot, "load_state", fake_load_state) - monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current_state: (current_state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current_state: (current_state, [])) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed"))) - output_path = tmp_path / "github-output.txt" - monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) - reviewer_bot.app_module.main(reviewer_bot) - assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" - assert len(saved_states) >= 2 + assert run.exit_code == 1 + assert captured.context is not None + assert captured.context.event_name == "issue_comment" + assert captured.context.event_action == "created" From 4be607aadc8e6f2b4260b3ec31a693324adf3bc6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 11:43:10 +0900 Subject: [PATCH 070/209] test(reviewer-bot): add a commands harness --- tests/fixtures/commands_harness.py | 123 ++++++++++++++++++ .../test_accept_no_fls_changes.py | 90 ++++++------- tests/unit/reviewer_bot/test_commands.py | 103 ++++++--------- 3 files changed, 208 insertions(+), 108 deletions(-) create mode 100644 tests/fixtures/commands_harness.py diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py new file mode 100644 index 000000000..a14f2b497 --- /dev/null +++ b/tests/fixtures/commands_harness.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import subprocess + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime + + +class _ConfigBag: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self._monkeypatch.setenv(name, rendered) + + +class AutomationRunner: + def __init__(self): + self._results: dict[tuple[str, ...], subprocess.CompletedProcess] = {} + self.calls: list[tuple[list[str], object, bool]] = [] + + def when(self, command: list[str], *, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self._results[tuple(command)] = subprocess.CompletedProcess(command, returncode, stdout=stdout, stderr=stderr) + + def run(self, command, cwd, check=False): + command_list = list(command) + self.calls.append((command_list, cwd, check)) + key = tuple(command_list) + if key not in self._results: + raise AssertionError(f"Unexpected command: {command_list}") + return self._results[key] + + +class CommandHarness: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.config = _ConfigBag(monkeypatch) + self.runtime = ReviewerBotRuntime(reviewer_bot, config=self.config) + self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) + + def set_comment_command( + self, + *, + issue_number: int, + actor: str, + body: str, + issue_author: str, + is_pull_request: bool = False, + author_association: str = "", + workflow_file: str = "", + repository: str = "", + ref: str = "", + comment_id: int = 100, + created_at: str = "2026-03-17T10:00:00Z", + ) -> None: + self.config.set("ISSUE_NUMBER", issue_number) + self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) + self.config.set("ISSUE_AUTHOR", issue_author) + self.config.set("COMMENT_USER_TYPE", "User") + self.config.set("COMMENT_AUTHOR", actor) + self.config.set("COMMENT_ID", comment_id) + self.config.set("COMMENT_CREATED_AT", created_at) + self.config.set("COMMENT_BODY", body) + if author_association: + self.config.set("COMMENT_AUTHOR_ASSOCIATION", author_association) + if workflow_file: + self.config.set("CURRENT_WORKFLOW_FILE", workflow_file) + if repository: + self.config.set("GITHUB_REPOSITORY", repository) + if ref: + self.config.set("GITHUB_REF", ref) + + def set_assignment_context(self, *, issue_author: str, is_pull_request: bool) -> None: + self.config.set("ISSUE_AUTHOR", issue_author) + self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) + + def set_privileged_context( + self, + *, + labels: list[str], + is_pull_request: bool = False, + target_repo_root=None, + ) -> None: + import json + + self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) + self.config.set("ISSUE_LABELS", json.dumps(labels)) + if target_repo_root is not None: + self.config.set("REVIEWER_BOT_TARGET_REPO_ROOT", target_repo_root) + + def set_manual_dispatch(self, *, source_event_key: str) -> None: + self.config.set("MANUAL_ACTION", "execute-pending-privileged-command") + self.config.set("PRIVILEGED_SOURCE_EVENT_KEY", source_event_key) + + def record_comments(self): + posted = [] + self._monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + return posted + + def stub_assignees(self, assignees): + self._monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: assignees) + + def stub_assignment(self, *, success: bool = True, status_code: int = 201): + self._monkeypatch.setattr( + reviewer_bot, + "request_reviewer_assignment", + lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=success, status_code=status_code), + ) + + def stub_permission(self, status: str) -> None: + self._monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": status) + + def automation_runner(self) -> AutomationRunner: + runner = AutomationRunner() + self._monkeypatch.setattr(reviewer_bot, "run_command", runner.run) + self._monkeypatch.setattr(reviewer_bot.automation_module, "run_command", runner.run) + return runner diff --git a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py index d343b0db2..e856a8623 100644 --- a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -1,44 +1,39 @@ -import json -import subprocess import pytest pytestmark = pytest.mark.integration from builder import build_cli from scripts import reviewer_bot +from tests.fixtures.commands_harness import CommandHarness -def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): - commands_seen = [] - - def fake_run_command(command, cwd, check=True): - commands_seen.append(command) - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - raise AssertionError(f"Unexpected command: {command}") - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) +def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_path): + harness = CommandHarness(monkeypatch) + runner = harness.automation_runner() + runner.when(["git", "diff", "--name-only"], stdout="") + runner.when(["git", "diff", "--cached", "--name-only"], stdout="") assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] - assert commands_seen == [["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]] + assert [command for command, _cwd, _check in runner.calls] == [ + ["git", "diff", "--name-only"], + ["git", "diff", "--cached", "--name-only"], + ] def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): - def fake_run_command(command, cwd, check=True): - if command == ["git", "diff", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="README.md\nsrc/spec.lock\n", stderr="") - if command == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(command, 0, stdout="src/spec.lock\n", stderr="") - raise AssertionError(f"Unexpected command: {command}") - - monkeypatch.setattr(reviewer_bot.automation_module, "run_command", fake_run_command) + harness = CommandHarness(monkeypatch) + runner = harness.automation_runner() + runner.when(["git", "diff", "--name-only"], stdout="README.md\nsrc/spec.lock\n") + runner.when(["git", "diff", "--cached", "--name-only"], stdout="src/spec.lock\n") assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + harness = CommandHarness(monkeypatch) + harness.set_privileged_context( + labels=[reviewer_bot.FLS_AUDIT_LABEL], + is_pull_request=False, + target_repo_root=tmp_path, + ) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) observed = {"cwd": None} @@ -54,9 +49,12 @@ def fake_list_changed_files(repo_root): assert observed["cwd"] == tmp_path def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + harness = CommandHarness(monkeypatch) + harness.set_privileged_context( + labels=[reviewer_bot.FLS_AUDIT_LABEL], + is_pull_request=False, + target_repo_root=tmp_path, + ) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) list_calls = {"count": 0} @@ -65,40 +63,36 @@ def fake_list_changed_files(repo_root): assert repo_root == tmp_path return [] - commands = [] - - def fake_run_command(command, cwd, check=False): - commands.append((command, cwd, check)) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + runner = harness.automation_runner() + runner.when(["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"]) + runner.when(["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"]) monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) assert list_calls["count"] == 2 - assert commands == [ + assert runner.calls == [ (["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], tmp_path, False), (["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], tmp_path, False), ] def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): - monkeypatch.setenv("REVIEWER_BOT_TARGET_REPO_ROOT", str(tmp_path)) - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) + harness = CommandHarness(monkeypatch) + harness.set_privileged_context( + labels=[reviewer_bot.FLS_AUDIT_LABEL], + is_pull_request=False, + target_repo_root=tmp_path, + ) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) - - def fake_run_command(command, cwd, check=False): - return subprocess.CompletedProcess( - command, - 1, - stdout="", - stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", - ) - - monkeypatch.setattr(reviewer_bot, "run_command", fake_run_command) + runner = harness.automation_runner() + runner.when( + ["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], + returncode=1, + stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", + ) message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index fff6acb0b..927d8d238 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -1,25 +1,25 @@ -import json import pytest from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing +from tests.fixtures.commands_harness import CommandHarness from tests.fixtures.reviewer_bot import make_state def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") + harness.set_comment_command( + issue_number=42, + actor="alice", + body="@guidelines-bot /label +sign-off: create pr", + issue_author="dana", + is_pull_request=False, + ) monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) monkeypatch.setattr( @@ -28,8 +28,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat lambda *args, **kwargs: pytest.fail("status sync should run only from app orchestration after save"), ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + posted = harness.record_comments() assert reviewer_bot.handle_comment_event(state) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" @@ -38,22 +37,22 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /label +sign-off: create pr") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + harness.set_comment_command( + issue_number=42, + actor="alice", + body="@guidelines-bot /label +sign-off: create pr", + issue_author="dana", + is_pull_request=True, + author_association="MEMBER", + workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + repository="rustfoundation/safety-critical-rust-coding-guidelines", + ref="refs/heads/main", + ) monkeypatch.setattr( reviewer_bot, "github_api", @@ -103,16 +102,12 @@ def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): def test_assign_command_posts_pr_guidance_on_success(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), - ) + harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + harness.stub_assignees([]) + harness.stub_assignment() posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) @@ -124,16 +119,12 @@ def test_assign_command_posts_pr_guidance_on_success(monkeypatch): def test_claim_command_posts_pr_guidance_on_success(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), - ) + harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + harness.stub_assignees([]) + harness.stub_assignment() posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) @@ -145,6 +136,7 @@ def test_claim_command_posts_pr_guidance_on_success(monkeypatch): def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [ {"github": "alice", "name": "Alice"}, @@ -153,14 +145,9 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: ["alice"]) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), - ) + harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + harness.stub_assignees(["alice"]) + harness.stub_assignment() monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: True) posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) @@ -173,16 +160,12 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): def test_assign_from_queue_posts_guidance_only_once(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_AUTHOR", "PLeVasseur") - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=True, status_code=201), - ) + harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + harness.stub_assignees([]) + harness.stub_assignment() posted = [] monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) @@ -194,9 +177,9 @@ def test_assign_from_queue_posts_guidance_only_once(monkeypatch): def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_LABELS", json.dumps([reviewer_bot.FLS_AUDIT_LABEL])) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + harness = CommandHarness(monkeypatch) + harness.set_privileged_context(labels=[reviewer_bot.FLS_AUDIT_LABEL], is_pull_request=False) + harness.stub_permission("unavailable") message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") @@ -318,6 +301,7 @@ def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavail def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -330,8 +314,7 @@ def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privilege "status": "pending", } } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + harness.set_manual_dispatch(source_event_key="issue_comment:100") monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", From 51d9954dd2b276f6cc6ce53e055dc2ae40d00ce5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 11:49:26 +0900 Subject: [PATCH 071/209] test(reviewer-bot): add a comment routing harness --- tests/fixtures/comment_routing_harness.py | 152 ++++++++++++++++++ .../test_comment_routing_integration.py | 146 +++++++++-------- .../unit/reviewer_bot/test_comment_routing.py | 137 +++++++++++----- 3 files changed, 325 insertions(+), 110 deletions(-) create mode 100644 tests/fixtures/comment_routing_harness.py diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py new file mode 100644 index 000000000..2f9f5e031 --- /dev/null +++ b/tests/fixtures/comment_routing_harness.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime + +from .reviewer_bot_fakes import RouteGitHubApi + + +class _ConfigBag: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self._monkeypatch.setenv(name, rendered) + + +@dataclass +class SideEffects: + comments: list[tuple[int, str]] + reactions: list[tuple[int, str]] + + +class CommentRoutingHarness: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.config = _ConfigBag(monkeypatch) + self.github = RouteGitHubApi() + self.runtime = ReviewerBotRuntime(reviewer_bot, config=self.config, github=self.github) + self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) + + def request( + self, + *, + issue_number: int, + is_pull_request: bool, + issue_state: str = "open", + issue_author: str = "", + comment_id: int = 100, + comment_author: str, + comment_body: str, + comment_created_at: str = "2026-03-17T10:00:00Z", + comment_source_event_key: str = "", + comment_user_type: str = "User", + ) -> CommentEventRequest: + return CommentEventRequest( + issue_number=issue_number, + is_pull_request=is_pull_request, + issue_state=issue_state, + issue_author=issue_author, + comment_id=comment_id, + comment_author=comment_author, + comment_body=comment_body, + comment_created_at=comment_created_at, + comment_source_event_key=comment_source_event_key, + comment_user_type=comment_user_type, + ) + + def trust_context( + self, + *, + github_repository: str = "", + comment_author_association: str = "", + current_workflow_file: str = "", + github_ref: str = "", + github_run_id: int = 0, + github_run_attempt: int = 0, + ) -> PrCommentTrustContext: + return PrCommentTrustContext( + github_repository=github_repository, + comment_author_association=comment_author_association, + current_workflow_file=current_workflow_file, + github_ref=github_ref, + github_run_id=github_run_id, + github_run_attempt=github_run_attempt, + ) + + def add_pull_request_metadata( + self, + *, + issue_number: int, + head_repo_full_name: str, + pr_author: str, + ) -> None: + payload = { + "head": {"repo": {"full_name": head_repo_full_name}}, + "user": {"login": pr_author}, + } + self.github.add_api( + "GET", + f"pulls/{issue_number}", + payload, + ) + self.github.add_request("GET", f"pulls/{issue_number}", status_code=200, payload=payload) + + def side_effects(self) -> SideEffects: + comments: list[tuple[int, str]] = [] + reactions: list[tuple[int, str]] = [] + self._monkeypatch.setattr( + reviewer_bot, + "post_comment", + lambda issue_number, body: comments.append((issue_number, body)) or True, + ) + self._monkeypatch.setattr( + reviewer_bot, + "add_reaction", + lambda comment_id, reaction: reactions.append((comment_id, reaction)) or True, + ) + return SideEffects(comments=comments, reactions=reactions) + + def set_wrapper_env( + self, + *, + issue_number: int, + is_pull_request: bool, + issue_state: str = "open", + issue_author: str = "", + comment_id: int = 100, + comment_author: str, + comment_body: str, + comment_created_at: str = "2026-03-17T10:00:00Z", + comment_user_type: str = "User", + comment_author_association: str = "", + current_workflow_file: str = "", + github_repository: str = "", + github_ref: str = "", + ) -> None: + self.config.set("ISSUE_NUMBER", issue_number) + self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) + self.config.set("ISSUE_STATE", issue_state) + self.config.set("ISSUE_AUTHOR", issue_author) + self.config.set("COMMENT_ID", comment_id) + self.config.set("COMMENT_AUTHOR", comment_author) + self.config.set("COMMENT_BODY", comment_body) + self.config.set("COMMENT_CREATED_AT", comment_created_at) + self.config.set("COMMENT_USER_TYPE", comment_user_type) + if comment_author_association: + self.config.set("COMMENT_AUTHOR_ASSOCIATION", comment_author_association) + if current_workflow_file: + self.config.set("CURRENT_WORKFLOW_FILE", current_workflow_file) + if github_repository: + self.config.set("GITHUB_REPOSITORY", github_repository) + if github_ref: + self.config.set("GITHUB_REF", github_ref) diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index 4843b2cfb..5b17ef05e 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -1,121 +1,125 @@ import json + import pytest pytestmark = pytest.mark.integration from scripts import reviewer_bot +from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state + def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() entry = reviewer_bot.ensure_review_entry(state, 42, create=True) assert entry is not None entry["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_author="dana", + comment_author="dana", + comment_body="@guidelines-bot /accept-no-fls-changes", + ) + effects = harness.side_effects() monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "granted") - assert reviewer_bot.handle_comment_event(state) is True + assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is True pending = state["active_reviews"]["42"]["pending_privileged_commands"] assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" assert pending["issue_comment:100"]["authorization"]["authorized"] is True + assert effects.comments == [ + ( + 42, + "✅ Recorded pending privileged command `accept-no-fls-changes` from trusted live validation. " + "Use the isolated privileged workflow to execute it from issue `#314` state.", + ) + ] + assert effects.reactions == [] def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - - assert reviewer_bot.handle_comment_event(state) is False + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_state="closed", + issue_author="dana", + comment_author="dana", + comment_body="reviewer-bot validation: close comment", + ) + + assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False assert state["active_reviews"] == {} def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /accept-no-fls-changes") - called = {"post_comment": 0} + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_state="closed", + issue_author="dana", + comment_author="dana", + comment_body="@guidelines-bot /accept-no-fls-changes", + ) + effects = harness.side_effects() monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr( - reviewer_bot, - "post_comment", - lambda *args, **kwargs: called.__setitem__("post_comment", called["post_comment"] + 1) or True, - ) - assert reviewer_bot.handle_comment_event(state) is False + assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False assert state["active_reviews"] == {} - assert called["post_comment"] == 0 + assert effects.comments == [] def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_state="closed", + issue_author="dana", + comment_author="dana", + comment_body="reviewer-bot validation: close comment", + ) - assert reviewer_bot.handle_comment_event(state) is True + assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is True assert "42" not in state["active_reviews"] def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: close comment") - - assert reviewer_bot.handle_comment_event(state) is False + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_state="closed", + issue_author="dana", + comment_author="dana", + comment_body="reviewer-bot validation: close comment", + ) + + assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False assert state["active_reviews"] == {} def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "open") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation: contributor plain text comment") + harness.set_wrapper_env( + issue_number=42, + is_pull_request=False, + issue_state="open", + issue_author="dana", + comment_author="dana", + comment_body="reviewer-bot validation: contributor plain text comment", + ) assert reviewer_bot.handle_comment_event(state) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 812c5a4e0..85fe3966d 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -2,10 +2,13 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing +from scripts.reviewer_bot_lib.context import CommentEventRequest +from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -20,16 +23,21 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_ timestamp="2026-03-17T09:00:00Z", actor="alice", ) - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_author="dana", + comment_id=100, + comment_author="alice", + comment_body="hello", + comment_created_at="2026-03-17T10:00:00Z", + comment_source_event_key="issue_comment:100", + ) changed = reviewer_bot.comment_routing_module._record_conversation_freshness( reviewer_bot, state, - 42, - "alice", - 100, - "2026-03-17T10:00:00Z", + request, ) assert changed is True @@ -48,9 +56,21 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_ ], ) def test_classify_issue_comment_actor(monkeypatch, env, expected): - for key, value in env.items(): - monkeypatch.setenv(key, value) - assert comment_routing.classify_issue_comment_actor() == expected + harness = CommentRoutingHarness(monkeypatch) + request = harness.request( + issue_number=42, + is_pull_request=False, + comment_author=env.get("COMMENT_AUTHOR", ""), + comment_body="hello", + comment_user_type=env.get("COMMENT_USER_TYPE", ""), + ) + request = CommentEventRequest( + **{ + **request.__dict__, + "comment_installation_id": env.get("COMMENT_INSTALLATION_ID", ""), + } + ) + assert comment_routing.classify_issue_comment_actor(request) == expected def test_classify_comment_payload_distinguishes_command_plus_text(): @@ -60,39 +80,78 @@ def test_classify_comment_payload_distinguishes_command_plus_text(): def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, + harness = CommentRoutingHarness(monkeypatch) + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="carol", + comment_author="alice", + comment_body="hello", ) - assert comment_routing.route_issue_comment_trust(reviewer_bot, 42) == "pr_trusted_direct" + trust_context = harness.trust_context( + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_ref="refs/heads/main", + ) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", + pr_author="carol", + ) + assert comment_routing.route_issue_comment_trust(reviewer_bot, 42, request, trust_context) == "pr_trusted_direct" def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("COMMENT_USER_TYPE", "") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "carol"}, - }, + harness = CommentRoutingHarness(monkeypatch) + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="carol", + comment_author="alice", + comment_body="hello", + comment_user_type="", + ) + trust_context = harness.trust_context( + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_ref="refs/heads/main", + ) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", + pr_author="carol", ) with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): - comment_routing.route_issue_comment_trust(reviewer_bot, 42) + comment_routing.route_issue_comment_trust(reviewer_bot, 42, request, trust_context) + + +def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + harness.set_wrapper_env( + issue_number=42, + is_pull_request=True, + issue_author="dana", + comment_author="alice", + comment_body="@guidelines-bot /queue", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + github_ref="refs/heads/main", + ) + harness.config.set("GITHUB_RUN_ID", 777) + harness.config.set("GITHUB_RUN_ATTEMPT", 2) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", + pr_author="dana", + ) + + payload = reviewer_bot.build_pr_comment_observer_payload(42) + + assert payload["kind"] == "observer_noop" + assert payload["reason"] == "trusted_direct_same_repo_human_comment" + assert payload["source_run_id"] == 777 + assert payload["source_run_attempt"] == 2 + assert payload["pr_number"] == 42 From c45ea07c121c069b0a01c32b69966c3dbe9b6e97 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 11:57:02 +0900 Subject: [PATCH 072/209] test(reviewer-bot): streamline review state scenarios --- tests/fixtures/reviewer_bot_fakes.py | 16 ++ tests/unit/reviewer_bot/test_overdue.py | 263 +++++++++--------- tests/unit/reviewer_bot/test_project_board.py | 42 +-- .../reviewer_bot/test_reviews_live_fetch.py | 115 ++++---- 4 files changed, 203 insertions(+), 233 deletions(-) diff --git a/tests/fixtures/reviewer_bot_fakes.py b/tests/fixtures/reviewer_bot_fakes.py index bd8a5edf7..d26ccd9a9 100644 --- a/tests/fixtures/reviewer_bot_fakes.py +++ b/tests/fixtures/reviewer_bot_fakes.py @@ -82,6 +82,22 @@ def add_api(self, method: str, endpoint: str, payload: Any) -> "RouteGitHubApi": self._api_routes[(method, endpoint)] = payload return self + def add_pull_request_snapshot(self, issue_number: int, payload: Any) -> "RouteGitHubApi": + return self.add_api("GET", f"pulls/{issue_number}", payload).add_request( + "GET", + f"pulls/{issue_number}", + status_code=200, + payload=payload, + ) + + def add_pull_request_reviews(self, issue_number: int, reviews: Any, *, page: int = 1) -> "RouteGitHubApi": + return self.add_request( + "GET", + f"pulls/{issue_number}/reviews?per_page=100&page={page}", + status_code=200, + payload=reviews, + ) + def raise_system_exit_on_request(self) -> "RouteGitHubApi": self._raise_system_exit_on_request = True return self diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py index 27f0d030b..a9ff2d1ff 100644 --- a/tests/unit/reviewer_bot/test_overdue.py +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -1,34 +1,44 @@ from datetime import timedelta from scripts import reviewer_bot -from tests.fixtures.reviewer_bot import iso_z, make_state +from tests.fixtures.github import RouteGitHubApi +from tests.fixtures.reviewer_bot import ( + accept_contributor_comment, + accept_contributor_revision, + accept_reviewer_review, + iso_z, + issue_snapshot, + make_state, + make_tracked_review_state, + pull_request_payload, + review_payload, +) def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-01T00:00:00Z", + active_cycle_started_at="2026-03-01T00:00:00Z", + ) + accept_reviewer_review( + review, + semantic_key="pull_request_review:10", + timestamp="2026-03-02T00:00:00Z", + actor="alice", + reviewed_head_sha="head-1", ) monkeypatch.setattr( reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) monkeypatch.setattr( reviewer_bot.reviews_module, @@ -66,37 +76,34 @@ def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_retu reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) contributor_comment_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_comment"]["accepted"] = { - "semantic_key": "issue_comment:20", - "timestamp": contributor_comment_at, - "actor": "bob", - "reviewed_head_sha": None, - "source_precedence": 0, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at=assigned_at, + active_cycle_started_at=assigned_at, + ) + accept_reviewer_review( + review, + semantic_key="pull_request_review:10", + timestamp=reviewer_review_at, + actor="alice", + reviewed_head_sha="head-1", + ) + accept_contributor_comment( + review, + semantic_key="issue_comment:20", + timestamp=contributor_comment_at, + actor="bob", ) monkeypatch.setattr( reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) monkeypatch.setattr( reviewer_bot.reviews_module, @@ -115,37 +122,35 @@ def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_cha reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) contributor_revision_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = assigned_at - review["active_cycle_started_at"] = assigned_at - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": reviewer_review_at, - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_sync:42:head-2", - "timestamp": contributor_revision_at, - "actor": None, - "reviewed_head_sha": "head-2", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at=assigned_at, + active_cycle_started_at=assigned_at, + ) + accept_reviewer_review( + review, + semantic_key="pull_request_review:10", + timestamp=reviewer_review_at, + actor="alice", + reviewed_head_sha="head-1", + ) + accept_contributor_revision( + review, + semantic_key="pull_request_sync:42:head-2", + timestamp=contributor_revision_at, + actor="alice", + head_sha="head-2", ) monkeypatch.setattr( reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None, + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-2")).add_pull_request_reviews(42, []) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) assert overdue[0]["issue_number"] == 42 @@ -155,37 +160,35 @@ def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_cha def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - review["reviewer_review"]["accepted"] = { - "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", - "actor": "alice", - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - review["contributor_revision"]["accepted"] = { - "semantic_key": "pull_request_head_observed:42:head-1", - "timestamp": "2026-03-12T00:00:00Z", - "actor": None, - "reviewed_head_sha": "head-1", - "source_precedence": 1, - "payload": {}, - } - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-01T00:00:00Z", + active_cycle_started_at="2026-03-01T00:00:00Z", + ) + accept_reviewer_review( + review, + semantic_key="pull_request_review:10", + timestamp="2026-03-02T00:00:00Z", + actor="alice", + reviewed_head_sha="head-1", + ) + accept_contributor_revision( + review, + semantic_key="pull_request_head_observed:42:head-1", + timestamp="2026-03-12T00:00:00Z", + actor="alice", + head_sha="head-1", ) monkeypatch.setattr( reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, + "get_issue_or_pr_snapshot", + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews(42, []) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) monkeypatch.setattr( reviewer_bot.reviews_module, @@ -197,50 +200,34 @@ def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_vali def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["assigned_at"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review = make_tracked_review_state( + state, + 42, + reviewer="alice", + assigned_at="2026-03-01T00:00:00Z", + active_cycle_started_at="2026-03-01T00:00:00Z", + ) + accept_reviewer_review( review, - "reviewer_review", semantic_key="pull_request_review:99", timestamp="2026-03-02T00:00:00Z", actor="alice", reviewed_head_sha="head-0", - source_precedence=1, ) monkeypatch.setattr( reviewer_bot, "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-20T00:00:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-21T00:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [ + review_payload(10, state="COMMENTED", submitted_at="2026-03-20T00:00:00Z", commit_id="head-1", author="alice"), + review_payload(99, state="COMMENTED", submitted_at="2026-03-21T00:00:00Z", commit_id="head-0", author="alice"), ], ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr( reviewer_bot.reviews_module, "rebuild_pr_approval_state", @@ -253,10 +240,12 @@ def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_ def test_refresh_reviewer_review_from_live_preferred_review_does_not_clear_transition_warning_when_activity_not_advanced( monkeypatch, ): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None - review["current_reviewer"] = "alice" - review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" + review = make_tracked_review_state( + make_state(), + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) review["reviewer_review"] = { "accepted": { "semantic_key": "pull_request_review:10", diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 2ed7af174..e59b91bbe 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -1,10 +1,13 @@ from scripts import reviewer_bot +from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import ( accept_reviewer_comment, accept_reviewer_review, issue_snapshot, make_state, make_tracked_review_state, + pull_request_payload, + review_payload, valid_reviewer_board_metadata, ) @@ -126,11 +129,9 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): "get_issue_or_pr_snapshot", lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) monkeypatch.setattr( reviewer_bot.reviews_module, "rebuild_pr_approval_state", @@ -166,31 +167,16 @@ def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state( "get_issue_or_pr_snapshot", lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - { - "id": 99, - "state": "COMMENTED", - "submitted_at": "2026-03-17T11:00:00Z", - "commit_id": "head-0", - "user": {"login": "alice"}, - }, + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [ + review_payload(10, state="COMMENTED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice"), + review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice"), ], ) + monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") desired_labels, _ = reviewer_bot.project_status_labels_for_item(42, state) preview = reviewer_bot.preview_board_projection_for_item(state, 42) diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index a73fdedfb..7168f6dec 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -22,24 +22,17 @@ def test_project_status_labels_uses_live_current_reviewer_review_when_channel_st reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z", ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ) - ], - ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) + ], ) monkeypatch.setattr( reviewer_bot, @@ -72,31 +65,24 @@ def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live reviewed_head_sha="head-0", source_precedence=1, ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ), - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ), - ], - ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), + ], ) monkeypatch.setattr( reviewer_bot, @@ -136,31 +122,24 @@ def test_project_status_labels_refreshes_stale_stored_review_from_live_current_h reviewed_head_sha="head-0", source_precedence=1, ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ), - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ), - ], - ) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [ + review_payload( + 10, + state="COMMENTED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ), + review_payload( + 99, + state="COMMENTED", + submitted_at="2026-03-17T11:00:00Z", + commit_id="head-0", + author="alice", + ), + ], ) monkeypatch.setattr( reviewer_bot, From 36ff400415f418dc39df8d63b4aeb5a7c41ed60d Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:00:26 +0900 Subject: [PATCH 073/209] test(reviewer-bot): contract the shared GitHub transport fake --- .../test_fake_github_transport_contract.py | 99 +++++++++++++++++++ .../reviewer_bot/test_fixture_contracts.py | 76 ++------------ 2 files changed, 106 insertions(+), 69 deletions(-) create mode 100644 tests/contract/reviewer_bot/test_fake_github_transport_contract.py diff --git a/tests/contract/reviewer_bot/test_fake_github_transport_contract.py b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py new file mode 100644 index 000000000..fdab84965 --- /dev/null +++ b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py @@ -0,0 +1,99 @@ +import pytest + +from tests.fixtures.github import RouteGitHubApi, github_result + +pytestmark = pytest.mark.contract + + +def test_github_result_builds_success_shape(): + result = github_result(200, {"ok": True}, headers={"ETag": "abc"}) + + assert result.status_code == 200 + assert result.payload == {"ok": True} + assert result.headers == {"etag": "abc"} + assert result.ok is True + assert result.failure_kind is None + + +def test_github_result_builds_not_found_shape(): + result = github_result(404, {"message": "missing"}) + + assert result.ok is False + assert result.failure_kind == "not_found" + assert result.text == "missing" + + +def test_github_result_builds_server_error_shape(): + result = github_result(502, {"message": "bad gateway"}) + + assert result.ok is False + assert result.failure_kind == "server_error" + assert result.text == "bad gateway" + + +def test_route_github_api_request_supports_invalid_payload_routes(): + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload=["not", "a", "dict"]) + + result = routes.github_api_request("GET", "pulls/42") + + assert result.ok is True + assert result.payload == ["not", "a", "dict"] + + +def test_route_github_api_raises_system_exit_on_request_and_keeps_api_mode(): + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42/reviews?per_page=100&page=1", [{"id": 10}]) + .raise_system_exit_on_request() + ) + + with pytest.raises(SystemExit): + routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") + + assert routes.github_api("GET", "pulls/42/reviews?per_page=100&page=1") == [{"id": 10}] + + +def test_route_github_api_supports_paginated_review_endpoints(): + routes = ( + RouteGitHubApi() + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=1", + status_code=200, + payload=[{"id": index} for index in range(100)], + ) + .add_request( + "GET", + "pulls/42/reviews?per_page=100&page=2", + status_code=200, + payload=[{"id": 100}], + ) + ) + + first = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") + second = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=2") + + assert len(first.payload) == 100 + assert second.payload == [{"id": 100}] + assert routes.requested_endpoints() == [ + "pulls/42/reviews?per_page=100&page=1", + "pulls/42/reviews?per_page=100&page=2", + ] + + +def test_route_github_api_add_pull_request_snapshot_registers_api_and_request_modes(): + routes = RouteGitHubApi().add_pull_request_snapshot(42, {"head": {"sha": "head-1"}}) + + assert routes.github_api("GET", "pulls/42") == {"head": {"sha": "head-1"}} + result = routes.github_api_request("GET", "pulls/42") + assert result.ok is True + assert result.payload == {"head": {"sha": "head-1"}} + + +def test_route_github_api_add_pull_request_reviews_registers_page_route(): + routes = RouteGitHubApi().add_pull_request_reviews(42, [{"id": 10}], page=2) + + result = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=2") + + assert result.ok is True + assert result.payload == [{"id": 10}] diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index db8307195..3514ad0e3 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -1,79 +1,17 @@ -import pytest - from tests.fixtures.github import RouteGitHubApi, github_result -def test_github_result_builds_success_shape(): - result = github_result(200, {"ok": True}, headers={"ETag": "abc"}) +def test_shared_github_fixture_exports_core_transport_helpers(): + routes = RouteGitHubApi() + result = github_result(200, {"ok": True}) - assert result.status_code == 200 - assert result.payload == {"ok": True} - assert result.headers == {"etag": "abc"} + assert routes is not None assert result.ok is True - assert result.failure_kind is None - - -def test_github_result_builds_not_found_shape(): - result = github_result(404, {"message": "missing"}) - - assert result.ok is False - assert result.failure_kind == "not_found" - assert result.text == "missing" -def test_github_result_builds_server_error_shape(): - result = github_result(502, {"message": "bad gateway"}) - - assert result.ok is False - assert result.failure_kind == "server_error" - assert result.text == "bad gateway" - - -def test_route_github_api_supports_invalid_payload_routes(): - routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload=["not", "a", "dict"]) +def test_shared_github_fixture_smoke_routes_simple_request(): + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload={"head": {"sha": "head-1"}}) result = routes.github_api_request("GET", "pulls/42") - assert result.ok is True - assert result.payload == ["not", "a", "dict"] - - -def test_route_github_api_raises_system_exit_on_request_and_keeps_api_mode(): - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42/reviews?per_page=100&page=1", [{"id": 10}]) - .raise_system_exit_on_request() - ) - - with pytest.raises(SystemExit): - routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") - - assert routes.github_api("GET", "pulls/42/reviews?per_page=100&page=1") == [{"id": 10}] - - -def test_route_github_api_supports_paginated_review_endpoints(): - routes = ( - RouteGitHubApi() - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[{"id": index} for index in range(100)], - ) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=2", - status_code=200, - payload=[{"id": 100}], - ) - ) - - first = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=1") - second = routes.github_api_request("GET", "pulls/42/reviews?per_page=100&page=2") - - assert len(first.payload) == 100 - assert second.payload == [{"id": 100}] - assert routes.requested_endpoints() == [ - "pulls/42/reviews?per_page=100&page=1", - "pulls/42/reviews?per_page=100&page=2", - ] + assert result.payload == {"head": {"sha": "head-1"}} From 37884dcf0fbb47a119c225c4d147172ddfc0942a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:11:18 +0900 Subject: [PATCH 074/209] test(reviewer-bot): add sweeper scenario builders --- .../test_workflow_artifact_contracts.py | 8 +- .../fixtures/reviewer_bot_sweeper_builders.py | 80 +++++++++ tests/unit/reviewer_bot/test_sweeper_logic.py | 155 ++++++------------ 3 files changed, 136 insertions(+), 107 deletions(-) create mode 100644 tests/fixtures/reviewer_bot_sweeper_builders.py diff --git a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py index 2d53adcfa..ebf3ec28a 100644 --- a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py +++ b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py @@ -1,10 +1,12 @@ from pathlib import Path + import pytest pytestmark = pytest.mark.contract from scripts import reviewer_bot + @pytest.mark.parametrize( ("workflow_path", "artifact_name", "payload_name"), [ @@ -118,7 +120,7 @@ def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatc } with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") @@ -134,7 +136,7 @@ def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(mo } with pytest.raises(RuntimeError, match="run_attempt mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") @@ -149,4 +151,4 @@ def test_validate_workflow_run_artifact_identity_requires_successful_conclusion( } with pytest.raises(RuntimeError, match="did not conclude successfully"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(payload) + reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) diff --git a/tests/fixtures/reviewer_bot_sweeper_builders.py b/tests/fixtures/reviewer_bot_sweeper_builders.py new file mode 100644 index 000000000..89959ed52 --- /dev/null +++ b/tests/fixtures/reviewer_bot_sweeper_builders.py @@ -0,0 +1,80 @@ +def issue_comment_event(comment_id: int, *, created_at: str, login: str = "alice", user_type: str = "User") -> dict: + return { + "id": comment_id, + "created_at": created_at, + "user": {"login": login, "type": user_type}, + } + + +def review_comment_event(comment_id: int, *, created_at: str, login: str = "dana", user_type: str = "User") -> dict: + return { + "id": comment_id, + "created_at": created_at, + "user": {"login": login, "type": user_type}, + } + + +def pull_request_review_event( + review_id: int, + *, + submitted_at: str, + state: str, + commit_id: str | None = None, + login: str = "alice", + updated_at: str | None = None, +) -> dict: + payload = { + "id": review_id, + "submitted_at": submitted_at, + "state": state, + "user": {"login": login}, + } + if commit_id is not None: + payload["commit_id"] = commit_id + if updated_at is not None: + payload["updated_at"] = updated_at + return payload + + +def workflow_run( + run_id: int, + *, + event: str, + path: str, + created_at: str, + repo_full_name: str = "rustfoundation/safety-critical-rust-coding-guidelines", + pr_number: int | None = 42, + status: str | None = None, + conclusion: str | None = None, + name: str | None = None, +) -> dict: + payload = { + "id": run_id, + "event": event, + "path": path, + "created_at": created_at, + "repository": {"full_name": repo_full_name}, + "pull_requests": [] if pr_number is None else [{"number": pr_number}], + } + if status is not None: + payload["status"] = status + if conclusion is not None: + payload["conclusion"] = conclusion + if name is not None: + payload["name"] = name + return payload + + +def artifact_payload( + *, + source_event_key: str, + source_run_id: int, + pr_number: int = 42, + source_run_attempt: int = 1, +) -> dict: + return { + "source_event_key": source_event_key, + "source_run_id": source_run_id, + "source_run_attempt": source_run_attempt, + "pr_number": pr_number, + } diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index a0dbcb38e..69e78e62f 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -1,14 +1,31 @@ +import pytest + from scripts import reviewer_bot from scripts.reviewer_bot_lib import sweeper from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.reviewer_bot_sweeper_builders import ( + artifact_payload, + issue_comment_event, + pull_request_review_event, + review_comment_event, + workflow_run, +) + + +@pytest.fixture +def freeze_sweeper_now(monkeypatch): + def apply(timestamp: str) -> None: + monkeypatch.setattr( + sweeper, + "_now", + lambda: reviewer_bot.parse_github_timestamp(timestamp), + ) + return apply -def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), - ) + +def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -18,15 +35,15 @@ def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_di "github_api", lambda method, endpoint, data=None: { "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-25T10:00:00Z"}], + "issues/42/comments?per_page=100&page=1": [issue_comment_event(101, created_at="2026-03-25T10:00:00Z")], }.get(endpoint), ) monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", lambda issue_number: [ - {"id": 202, "submitted_at": "2026-03-25T11:00:00Z", "state": "APPROVED"}, - {"id": 303, "submitted_at": "2026-03-25T09:00:00Z", "updated_at": "2026-03-25T12:00:00Z", "state": "DISMISSED"}, + pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="APPROVED"), + pull_request_review_event(303, submitted_at="2026-03-25T09:00:00Z", updated_at="2026-03-25T12:00:00Z", state="DISMISSED"), ], ) @@ -38,12 +55,8 @@ def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_di assert gaps["pull_request_review_dismissed:303"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" -def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), - ) +def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -55,7 +68,7 @@ def fake_github_api(method, endpoint, data=None): if endpoint == "issues/42/comments?per_page=100&page=1": return [] if endpoint == "pulls/42/comments?per_page=100": - return [{"id": 404, "created_at": "2026-03-25T10:30:00Z", "user": {"login": "dana", "type": "User"}}] + return [review_comment_event(404, created_at="2026-03-25T10:30:00Z", login="dana")] if endpoint.startswith("actions/workflows/"): return {"workflow_runs": []} return None @@ -69,12 +82,8 @@ def fake_github_api(method, endpoint, data=None): assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" -def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-17T12:30:00Z"), - ) +def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-17T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -91,21 +100,15 @@ def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key( monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", - lambda issue_number: [ - {"id": 303, "submitted_at": "2026-03-17T09:00:00Z", "updated_at": "2026-03-17T12:00:00Z", "state": "DISMISSED"}, - ], + lambda issue_number: [pull_request_review_event(303, submitted_at="2026-03-17T09:00:00Z", updated_at="2026-03-17T12:00:00Z", state="DISMISSED")], ) assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} -def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-17T12:30:00Z"), - ) +def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-17T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -116,25 +119,21 @@ def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch "github_api", lambda method, endpoint, data=None: { "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [{"id": 101, "created_at": "2026-03-17T10:00:00Z"}], + "issues/42/comments?per_page=100&page=1": [issue_comment_event(101, created_at="2026-03-17T10:00:00Z")], }.get(endpoint), ) monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", - lambda issue_number: [{"id": 202, "submitted_at": "2026-03-17T11:00:00Z", "state": "APPROVED"}], + lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-17T11:00:00Z", state="APPROVED")], ) assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} -def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), - ) +def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -142,16 +141,8 @@ def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(m reviewer_bot, "github_api", lambda method, endpoint, data=None: [ - { - "id": 100, - "created_at": "2026-03-25T10:00:00Z", - "user": {"login": "github-actions[bot]", "type": "Bot"}, - }, - { - "id": 101, - "created_at": "2026-03-25T11:00:00Z", - "user": {"login": "alice", "type": "User"}, - }, + issue_comment_event(100, created_at="2026-03-25T10:00:00Z", login="github-actions[bot]", user_type="Bot"), + issue_comment_event(101, created_at="2026-03-25T11:00:00Z", login="alice"), ], ) @@ -161,12 +152,8 @@ def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(m assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] -def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), - ) +def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -185,15 +172,7 @@ def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_witho monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 202, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")], ) assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True @@ -204,12 +183,8 @@ def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_witho assert "pull_request_review:202" in review["reconciled_source_events"] -def test_visible_review_repair_does_not_clear_transition_warning_for_stale_replayed_review(monkeypatch): - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp("2026-03-25T12:30:00Z"), - ) +def test_visible_review_repair_does_not_clear_transition_warning_for_stale_replayed_review(monkeypatch, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -229,15 +204,7 @@ def test_visible_review_repair_does_not_clear_transition_warning_for_stale_repla monkeypatch.setattr( reviewer_bot, "get_pull_request_reviews", - lambda issue_number: [ - { - "id": 202, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], + lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")], ) assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True @@ -273,13 +240,7 @@ def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(m review, 42, "pull_request_review:303", - { - "id": 303, - "submitted_at": "2026-03-25T11:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, + pull_request_review_event(303, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1"), ) assert changed is True @@ -327,22 +288,8 @@ def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_win pr_number=42, workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", workflow_runs=[ - { - "id": 1, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:05:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, - { - "id": 2, - "event": "issue_comment", - "path": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "created_at": "2026-03-17T10:40:00Z", - "repository": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}, - "pull_requests": [{"number": 42}], - }, + workflow_run(1, event="issue_comment", path=".github/workflows/reviewer-bot-pr-comment-observer.yml", created_at="2026-03-17T10:05:00Z"), + workflow_run(2, event="issue_comment", path=".github/workflows/reviewer-bot-pr-comment-observer.yml", created_at="2026-03-17T10:40:00Z"), ], ) assert result["candidate_run_ids"] == [1] @@ -351,8 +298,8 @@ def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_win def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): result = sweeper.correlate_run_artifacts_exact( { - 10: [{"source_event_key": "issue_comment:101", "source_run_id": 10, "source_run_attempt": 1, "pr_number": 42}], - 11: [{"source_event_key": "issue_comment:101", "source_run_id": 11, "source_run_attempt": 1, "pr_number": 42}], + 10: [artifact_payload(source_event_key="issue_comment:101", source_run_id=10)], + 11: [artifact_payload(source_event_key="issue_comment:101", source_run_id=11)], }, "issue_comment:101", pr_number=42, From 07c28b3fc5d0c9fc6cf7f5af4eed2e4a50dacd42 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:21:14 +0900 Subject: [PATCH 075/209] test(reviewer-bot): add a shared app fake runtime --- tests/fixtures/app_harness.py | 41 ++--- tests/fixtures/fake_runtime.py | 142 ++++++++++++++++ .../test_app_closed_issue_cleanup.py | 103 ++++++------ .../reviewer_bot/test_app_execution.py | 81 ++++----- .../test_app_reviewer_board_preview.py | 155 +++++++----------- .../test_app_state_unavailable.py | 45 +++-- .../test_app_workflow_run_paths.py | 72 ++++---- 7 files changed, 354 insertions(+), 285 deletions(-) create mode 100644 tests/fixtures/fake_runtime.py diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index 180710691..51b1eb3e6 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -3,29 +3,8 @@ from dataclasses import dataclass from scripts import reviewer_bot -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime - -class _ConfigBag: - def __init__(self, monkeypatch): - self._monkeypatch = monkeypatch - self.values: dict[str, str] = {} - - def get(self, name: str, default: str = "") -> str: - return self.values.get(name, default) - - def set(self, name: str, value) -> None: - rendered = str(value) - self.values[name] = rendered - self._monkeypatch.setenv(name, rendered) - - -class _OutputCapture: - def __init__(self): - self.writes: list[tuple[str, str]] = [] - - def write(self, name: str, value: str) -> None: - self.writes.append((name, value)) +from .fake_runtime import FakeReviewerBotRuntime @dataclass @@ -37,19 +16,18 @@ class MainRun: class AppHarness: def __init__(self, monkeypatch): self._monkeypatch = monkeypatch - self.config = _ConfigBag(monkeypatch) - self.outputs = _OutputCapture() - runtime = ReviewerBotRuntime( - reviewer_bot, - config=self.config, - outputs=self.outputs, - ) - self._monkeypatch.setattr(reviewer_bot, "RUNTIME", runtime) + self.runtime = FakeReviewerBotRuntime(monkeypatch) + self.config = self.runtime.config + self.outputs = self.runtime.outputs + self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_event(self, **values) -> None: for name, value in values.items(): self.config.set(name, value) + def set_state_sequence(self, *states: dict) -> None: + self.runtime.stub_state_sequence(*states) + def stub_execute_run(self, result: reviewer_bot.ExecutionResult) -> MainRun: captured = MainRun(exit_code=None) @@ -60,6 +38,9 @@ def fake_execute_run(bot, context): self._monkeypatch.setattr(reviewer_bot.app_module, "execute_run", fake_execute_run) return captured + def run_execute(self): + return reviewer_bot.execute_run(reviewer_bot.build_event_context()) + def run_main(self) -> MainRun: try: reviewer_bot.main() diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py new file mode 100644 index 000000000..4918aeaaa --- /dev/null +++ b/tests/fixtures/fake_runtime.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any + +from scripts import reviewer_bot + + +class ConfigBag: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self._monkeypatch.setenv(name, rendered) + + +class OutputCapture: + def __init__(self): + self.writes: list[tuple[str, str]] = [] + + def write(self, name: str, value: str) -> None: + self.writes.append((name, value)) + + +class FakeReviewerBotRuntime: + EVENT_INTENT_MUTATING = reviewer_bot.EVENT_INTENT_MUTATING + EVENT_INTENT_NON_MUTATING_DEFER = reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + EVENT_INTENT_NON_MUTATING_READONLY = reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY + STATUS_PROJECTION_EPOCH = reviewer_bot.STATUS_PROJECTION_EPOCH + datetime = reviewer_bot.datetime + timezone = reviewer_bot.timezone + + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self._module = reviewer_bot + self.config = ConfigBag(monkeypatch) + self.outputs = OutputCapture() + self._load_state_impl = lambda *, fail_on_unavailable=False: reviewer_bot.load_state( + fail_on_unavailable=fail_on_unavailable + ) + self._save_state_impl = lambda state: reviewer_bot.save_state(state) + self._acquire_impl = lambda: reviewer_bot.acquire_state_issue_lease_lock() + self._release_impl = lambda: reviewer_bot.release_state_issue_lease_lock() + self._process_pass_until_impl = lambda state: reviewer_bot.process_pass_until_expirations(state) + self._sync_members_impl = lambda state: reviewer_bot.sync_members_with_queue(state) + self._sync_status_labels_impl = lambda state, issue_numbers: reviewer_bot.sync_status_labels_for_items( + state, issue_numbers + ) + self._touched_items: list[int] = [] + + def __getattr__(self, name: str): + return getattr(self._module, name) + + def get_config_value(self, name: str, default: str = "") -> str: + return self.config.get(name, default) + + def set_config_value(self, name: str, value: Any) -> None: + self.config.set(name, value) + + def write_output(self, name: str, value: str) -> None: + self.outputs.write(name, value) + + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + return self._load_state_impl(fail_on_unavailable=fail_on_unavailable) + + def save_state(self, state: dict) -> bool: + return self._save_state_impl(state) + + def acquire_state_issue_lease_lock(self): + return self._acquire_impl() + + def release_state_issue_lease_lock(self) -> bool: + return self._release_impl() + + def process_pass_until_expirations(self, state: dict): + return self._process_pass_until_impl(state) + + def sync_members_with_queue(self, state: dict): + return self._sync_members_impl(state) + + def sync_status_labels_for_items(self, state: dict, issue_numbers): + return self._sync_status_labels_impl(state, issue_numbers) + + def collect_touched_item(self, issue_number: int) -> None: + if issue_number not in self._touched_items: + self._touched_items.append(issue_number) + + def drain_touched_items(self) -> list[int]: + items = list(self._touched_items) + self._touched_items.clear() + return items + + def stub_state_sequence(self, *states: dict) -> None: + state_queue = [deepcopy(state) for state in states] + + def fake_load_state(*, fail_on_unavailable: bool = False): + if not state_queue: + raise AssertionError("No more fake states queued") + if len(state_queue) == 1: + return state_queue[0] + return state_queue.pop(0) + + self._load_state_impl = fake_load_state + + def stub_state_unavailable(self, message: str = "state unavailable") -> None: + def fake_load_state(*, fail_on_unavailable: bool = False): + assert fail_on_unavailable is True + raise RuntimeError(message) + + self._load_state_impl = fake_load_state + + def record_saves(self, snapshots: list): + def fake_save_state(state: dict) -> bool: + snapshots.append(json.loads(json.dumps(state))) + return True + + self._save_state_impl = fake_save_state + + def set_save_state(self, func) -> None: + self._save_state_impl = func + + def set_acquire_lock(self, func) -> None: + self._acquire_impl = func + + def set_release_lock(self, func) -> None: + self._release_impl = func + + def set_pass_until(self, func) -> None: + self._process_pass_until_impl = func + + def set_sync_members(self, func) -> None: + self._sync_members_impl = func + + def set_sync_status_labels(self, func) -> None: + self._sync_status_labels_impl = func diff --git a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py index f81fbca7c..f80fb3e57 100644 --- a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -1,21 +1,27 @@ +import pytest + from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state -import pytest pytestmark = pytest.mark.integration + def test_execute_run_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", + ISSUE_NUMBER=42, + IS_PULL_REQUEST="false", + ISSUE_STATE="closed", + ISSUE_AUTHOR="dana", + COMMENT_USER_TYPE="User", + COMMENT_AUTHOR="dana", + COMMENT_ID=100, + COMMENT_CREATED_AT="2026-03-17T10:00:00Z", + COMMENT_BODY="reviewer-bot validation close-path comment", + ) initial_state = make_state() review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) @@ -32,23 +38,15 @@ def fake_load_state(*, fail_on_unavailable=False): return initial_state return reloaded_state - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda state: save_calls.append("42" in state["active_reviews"]) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True, - ) + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = fake_load_state + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + harness.runtime.set_save_state(lambda state: save_calls.append("42" in state["active_reviews"]) or True) + harness.runtime.set_sync_status_labels(lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_calls == [False] @@ -57,39 +55,34 @@ def fake_load_state(*, fail_on_unavailable=False): assert sync_calls[0][1] == [42] def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_STATE", "closed") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "reviewer-bot validation close-path comment") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", + ISSUE_NUMBER=42, + IS_PULL_REQUEST="false", + ISSUE_STATE="closed", + ISSUE_AUTHOR="dana", + COMMENT_USER_TYPE="User", + COMMENT_AUTHOR="dana", + COMMENT_ID=100, + COMMENT_CREATED_AT="2026-03-17T10:00:00Z", + COMMENT_BODY="reviewer-bot validation close-path comment", + ) state = make_state() save_called = {"value": False} sync_calls = [] - 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, - "save_state", - lambda current: save_called.__setitem__("value", True) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False, - ) + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: state + harness.runtime.set_pass_until(lambda current: (current, [])) + harness.runtime.set_sync_members(lambda current: (current, [])) + harness.runtime.set_save_state(lambda current: save_called.__setitem__("value", True) or True) + harness.runtime.set_sync_status_labels(lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_called["value"] is False diff --git a/tests/integration/reviewer_bot/test_app_execution.py b/tests/integration/reviewer_bot/test_app_execution.py index fe440a393..418525b57 100644 --- a/tests/integration/reviewer_bot/test_app_execution.py +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -1,12 +1,14 @@ -from scripts import reviewer_bot -from tests.fixtures.reviewer_bot import make_state import pytest +from tests.fixtures.app_harness import AppHarness +from tests.fixtures.reviewer_bot import make_state + pytestmark = pytest.mark.integration + def test_execute_run_reloads_state_before_syncing_status_labels(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") initial_state = make_state() reloaded_state = make_state() @@ -22,7 +24,7 @@ def fake_load_state(*, fail_on_unavailable=False): def fake_handle_comment_event(state): assert state is initial_state - reviewer_bot.collect_touched_item(42) + harness.runtime.collect_touched_item(42) call_order.append("handle") return True @@ -37,16 +39,16 @@ def fake_sync_status_labels_for_items(state, issue_numbers): assert list(issue_numbers) == [42] return True - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", fake_handle_comment_event) - monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", fake_sync_status_labels_for_items) + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = fake_load_state + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_comment_event", fake_handle_comment_event) + harness.runtime.set_save_state(fake_save_state) + harness.runtime.set_sync_status_labels(fake_sync_status_labels_for_items) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert result.state_changed is True @@ -61,38 +63,37 @@ def fake_sync_status_labels_for_items(state, issue_numbers): ] def test_execute_run_returns_failure_when_save_state_fails(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - 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: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", lambda state: True) - monkeypatch.setattr(reviewer_bot, "save_state", lambda state: False) - - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_comment_event", lambda state: True) + harness.runtime.set_save_state(lambda state: False) + + result = harness.run_execute() assert result.exit_code == 1 assert result.state_changed is True def test_execute_run_returns_failure_for_invalid_workflow_run_context(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") - 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: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr( - reviewer_bot, - "handle_workflow_run_event", - lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context")), + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="pull_request_review", + WORKFLOW_RUN_EVENT_ACTION="submitted", ) - - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_workflow_run_event", lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context"))) + + result = harness.run_execute() assert result.exit_code == 1 assert result.state_changed is False diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py index ed4a989f6..11712da5a 100644 --- a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -1,91 +1,67 @@ +import pytest + from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata -import pytest pytestmark = pytest.mark.integration -def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "false") - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), +def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, capsys): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", + REVIEWER_BOARD_ENABLED="false", ) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.runtime.set_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.runtime.set_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) + harness.runtime.set_save_state(lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state"))) + harness.runtime.set_sync_status_labels(lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) + + result = harness.run_execute() assert result.exit_code == 0 output = capsys.readouterr().out assert "Reviewer board preview skipped: reviewer board is disabled." in output def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", + REVIEWER_BOARD_ENABLED="true", + ) monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), - ) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.runtime.set_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.runtime.set_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 1 assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", + REVIEWER_BOARD_ENABLED="true", + REVIEWER_BOARD_TOKEN="board-token", + ) monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) monkeypatch.setattr( reviewer_bot, "github_graphql", @@ -102,18 +78,21 @@ def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monke }, ) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 1 assert "Missing reviewer board field: Review State" in capsys.readouterr().err def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): - monkeypatch.setenv("EVENT_NAME", "workflow_dispatch") - monkeypatch.setenv("EVENT_ACTION", "") - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") - monkeypatch.setenv("ISSUE_NUMBER", "42") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", + REVIEWER_BOARD_ENABLED="true", + REVIEWER_BOARD_TOKEN="board-token", + ISSUE_NUMBER=42, + ) monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) state = make_state() @@ -124,32 +103,12 @@ def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): review["assigned_at"] = "2026-03-20T12:34:56Z" review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) - monkeypatch.setattr( - reviewer_bot, - "acquire_state_issue_lease_lock", - lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock")), - ) - monkeypatch.setattr( - reviewer_bot, - "process_pass_until_expirations", - lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_members_with_queue", - lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync")), - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state")), - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels")), - ) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: state + harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.runtime.set_pass_until(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.runtime.set_sync_members(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) + harness.runtime.set_save_state(lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state"))) + harness.runtime.set_sync_status_labels(lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) monkeypatch.setattr( reviewer_bot, @@ -157,7 +116,7 @@ def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, ) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 output = capsys.readouterr().out diff --git a/tests/integration/reviewer_bot/test_app_state_unavailable.py b/tests/integration/reviewer_bot/test_app_state_unavailable.py index 16faa18cc..0def27135 100644 --- a/tests/integration/reviewer_bot/test_app_state_unavailable.py +++ b/tests/integration/reviewer_bot/test_app_state_unavailable.py @@ -1,30 +1,27 @@ -from scripts import reviewer_bot import pytest -pytestmark = pytest.mark.integration +from tests.fixtures.app_harness import AppHarness -def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) +pytestmark = pytest.mark.integration - def fail_load(*, fail_on_unavailable=False): - assert fail_on_unavailable is True - raise RuntimeError("state unavailable") - monkeypatch.setattr(reviewer_bot, "load_state", fail_load) +def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) + harness.runtime.stub_state_unavailable("state unavailable") - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 1 assert result.state_changed is False def test_execute_run_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") + harness.runtime.set_acquire_lock(lambda: None) + harness.runtime.set_release_lock(lambda: True) called = { "pass_until": False, @@ -33,10 +30,6 @@ def test_execute_run_mutating_event_does_not_sync_or_save_when_state_unavailable "save": False, } - def fail_load(*, fail_on_unavailable=False): - assert fail_on_unavailable is True - raise RuntimeError("state unavailable") - def track_pass_until(state): called["pass_until"] = True return state, [] @@ -53,13 +46,13 @@ def track_save(state): called["save"] = True return True - monkeypatch.setattr(reviewer_bot, "load_state", fail_load) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", track_pass_until) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", track_sync) - monkeypatch.setattr(reviewer_bot, "handle_comment_event", track_handler) - monkeypatch.setattr(reviewer_bot, "save_state", track_save) + harness.runtime.stub_state_unavailable("state unavailable") + harness.runtime.set_pass_until(track_pass_until) + harness.runtime.set_sync_members(track_sync) + setattr(harness.runtime, "handle_comment_event", track_handler) + harness.runtime.set_save_state(track_save) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 1 assert called == { diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py index 0cf5c278b..749b8a6b3 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -1,13 +1,15 @@ +import pytest + from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state -import pytest pytestmark = pytest.mark.integration + def test_execute_run_cross_repo_review_does_not_acquire_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "pull_request_review") - monkeypatch.setenv("EVENT_ACTION", "submitted") - monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="pull_request_review", EVENT_ACTION="submitted", PR_IS_CROSS_REPOSITORY="true") acquire_called = {"value": False} @@ -15,17 +17,17 @@ def fail_if_called(): acquire_called["value"] = True raise AssertionError("acquire_state_issue_lease_lock should not be called") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + harness.runtime.set_acquire_lock(fail_if_called) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + setattr(harness.runtime, "handle_pull_request_review_event", lambda state: False) - reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.run_execute() assert acquire_called["value"] is False def test_execute_run_same_repo_review_does_not_acquire_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "pull_request_review") - monkeypatch.setenv("EVENT_ACTION", "submitted") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="pull_request_review", EVENT_ACTION="submitted") acquire_called = {"value": False} @@ -33,20 +35,19 @@ def fail_if_called(): acquire_called["value"] = True raise AssertionError("acquire_state_issue_lease_lock should not be called") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fail_if_called) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + harness.runtime.set_acquire_lock(fail_if_called) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_pull_request_review_event", lambda state: False) - reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.run_execute() assert acquire_called["value"] is False def test_execute_run_workflow_run_reconcile_acquires_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="workflow_run", EVENT_ACTION="completed", WORKFLOW_RUN_EVENT="pull_request_review") acquire_called = {"value": False} @@ -62,21 +63,20 @@ def fake_acquire(): lock_expires_at="2999-01-01T00:00:00+00:00", ) - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) + harness.runtime.set_acquire_lock(fake_acquire) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_workflow_run_event", lambda state: False) - reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.run_execute() assert acquire_called["value"] is True def test_execute_run_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="workflow_run", EVENT_ACTION="completed", WORKFLOW_RUN_EVENT="pull_request_review_comment") acquire_called = {"value": False} @@ -92,13 +92,13 @@ def fake_acquire(): lock_expires_at="2999-01-01T00:00:00+00:00", ) - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", fake_acquire) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda state: (state, [])) - monkeypatch.setattr(reviewer_bot, "handle_workflow_run_event", lambda state: False) + harness.runtime.set_acquire_lock(fake_acquire) + harness.runtime.set_release_lock(lambda: True) + harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() + harness.runtime.set_pass_until(lambda state: (state, [])) + harness.runtime.set_sync_members(lambda state: (state, [])) + setattr(harness.runtime, "handle_workflow_run_event", lambda state: False) - reviewer_bot.execute_run(reviewer_bot.build_event_context()) + harness.run_execute() assert acquire_called["value"] is True From bbc33fa55f562de330a701dc69cfbda435e37e46 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:29:30 +0900 Subject: [PATCH 076/209] test(reviewer-bot): share fake runtime across harnesses --- tests/fixtures/commands_harness.py | 19 +++-------------- tests/fixtures/comment_routing_harness.py | 20 +++--------------- tests/fixtures/fake_runtime.py | 25 ++++++++++++++++++++++- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index a14f2b497..9ffaa1634 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -3,21 +3,8 @@ import subprocess from scripts import reviewer_bot -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime - -class _ConfigBag: - def __init__(self, monkeypatch): - self._monkeypatch = monkeypatch - self.values: dict[str, str] = {} - - def get(self, name: str, default: str = "") -> str: - return self.values.get(name, default) - - def set(self, name: str, value) -> None: - rendered = str(value) - self.values[name] = rendered - self._monkeypatch.setenv(name, rendered) +from .fake_runtime import FakeReviewerBotRuntime class AutomationRunner: @@ -40,8 +27,8 @@ def run(self, command, cwd, check=False): class CommandHarness: def __init__(self, monkeypatch): self._monkeypatch = monkeypatch - self.config = _ConfigBag(monkeypatch) - self.runtime = ReviewerBotRuntime(reviewer_bot, config=self.config) + self.runtime = FakeReviewerBotRuntime(monkeypatch) + self.config = self.runtime.config self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_comment_command( diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 2f9f5e031..f19922b3b 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -4,25 +4,11 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_fakes import RouteGitHubApi -class _ConfigBag: - def __init__(self, monkeypatch): - self._monkeypatch = monkeypatch - self.values: dict[str, str] = {} - - def get(self, name: str, default: str = "") -> str: - return self.values.get(name, default) - - def set(self, name: str, value) -> None: - rendered = str(value) - self.values[name] = rendered - self._monkeypatch.setenv(name, rendered) - - @dataclass class SideEffects: comments: list[tuple[int, str]] @@ -32,9 +18,9 @@ class SideEffects: class CommentRoutingHarness: def __init__(self, monkeypatch): self._monkeypatch = monkeypatch - self.config = _ConfigBag(monkeypatch) self.github = RouteGitHubApi() - self.runtime = ReviewerBotRuntime(reviewer_bot, config=self.config, github=self.github) + self.runtime = FakeReviewerBotRuntime(monkeypatch, github=self.github) + self.config = self.runtime.config self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def request( diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 4918aeaaa..bb9901a44 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -37,9 +37,10 @@ class FakeReviewerBotRuntime: datetime = reviewer_bot.datetime timezone = reviewer_bot.timezone - def __init__(self, monkeypatch): + def __init__(self, monkeypatch, *, github=None): self._monkeypatch = monkeypatch self._module = reviewer_bot + self._github = github self.config = ConfigBag(monkeypatch) self.outputs = OutputCapture() self._load_state_impl = lambda *, fail_on_unavailable=False: reviewer_bot.load_state( @@ -88,6 +89,28 @@ def sync_members_with_queue(self, state: dict): def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._sync_status_labels_impl(state, issue_numbers) + def github_api(self, method: str, endpoint: str, data=None): + if self._github is not None: + return self._github.github_api(method, endpoint, data=data) + return self._module.github_api(method, endpoint, data=data) + + def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): + if self._github is not None: + return self._github.github_api_request( + method, + endpoint, + data=data, + extra_headers=extra_headers, + **kwargs, + ) + return self._module.github_api_request( + method, + endpoint, + data=data, + extra_headers=extra_headers, + **kwargs, + ) + def collect_touched_item(self, issue_number: int) -> None: if issue_number not in self._touched_items: self._touched_items.append(issue_number) From 11f6cf4eea71cbd8cab2656f10c00b1cb089128e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:31:33 +0900 Subject: [PATCH 077/209] test(reviewer-bot): contract the shared fake runtime --- .../test_fake_runtime_contract.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/contract/reviewer_bot/test_fake_runtime_contract.py diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py new file mode 100644 index 000000000..b7567cd89 --- /dev/null +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -0,0 +1,94 @@ +import pytest + +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.github import RouteGitHubApi + +pytestmark = pytest.mark.contract + + +def test_fake_runtime_config_writes_mirror_env(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + runtime.set_config_value("EVENT_NAME", "issue_comment") + + assert runtime.get_config_value("EVENT_NAME") == "issue_comment" + + +def test_fake_runtime_output_sink_records_writes(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + runtime.write_output("state_changed", "true") + + assert runtime.outputs.writes == [("state_changed", "true")] + + +def test_fake_runtime_touched_items_preserve_uniqueness_and_drain(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + runtime.collect_touched_item(42) + runtime.collect_touched_item(42) + runtime.collect_touched_item(99) + + assert runtime.drain_touched_items() == [42, 99] + assert runtime.drain_touched_items() == [] + + +def test_fake_runtime_stub_state_sequence_replays_until_last_snapshot(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.stub_state_sequence({"active_reviews": {"42": {}}}, {"active_reviews": {}}) + + first = runtime.load_state() + second = runtime.load_state() + third = runtime.load_state() + + assert first == {"active_reviews": {"42": {}}} + assert second == {"active_reviews": {}} + assert third == {"active_reviews": {}} + + +def test_fake_runtime_stub_state_unavailable_requires_fail_closed_load(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.stub_state_unavailable("state unavailable") + + with pytest.raises(RuntimeError, match="state unavailable"): + runtime.load_state(fail_on_unavailable=True) + + +def test_fake_runtime_record_saves_captures_structured_snapshots(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + snapshots = [] + runtime.record_saves(snapshots) + + state = {"active_reviews": {"42": {"current_reviewer": "alice"}}} + assert runtime.save_state(state) is True + state["active_reviews"]["42"]["current_reviewer"] = "bob" + + assert snapshots == [{"active_reviews": {"42": {"current_reviewer": "alice"}}}] + + +def test_fake_runtime_optional_lock_hooks_are_replaceable(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + calls = [] + runtime.set_acquire_lock(lambda: calls.append("acquire") or None) + runtime.set_release_lock(lambda: calls.append("release") or True) + + assert runtime.acquire_state_issue_lease_lock() is None + assert runtime.release_state_issue_lease_lock() is True + assert calls == ["acquire", "release"] + + +def test_fake_runtime_github_transport_delegates_to_shared_route_fake(monkeypatch): + github = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload={"head": {"sha": "head-1"}}) + runtime = FakeReviewerBotRuntime(monkeypatch, github=github) + + result = runtime.github_api_request("GET", "pulls/42") + + assert result.ok is True + assert result.payload == {"head": {"sha": "head-1"}} + + +def test_fake_runtime_github_api_mode_delegates_to_shared_route_fake(monkeypatch): + github = RouteGitHubApi().add_api("GET", "pulls/42", {"head": {"sha": "head-1"}}) + runtime = FakeReviewerBotRuntime(monkeypatch, github=github) + + assert runtime.github_api("GET", "pulls/42") == {"head": {"sha": "head-1"}} From 5aa7b8188a6268703c50edd226d786f27126d7dc Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:39:06 +0900 Subject: [PATCH 078/209] refactor(reviewer-bot): require typed comment routing inputs --- scripts/reviewer_bot_lib/comment_routing.py | 52 +++---------------- scripts/reviewer_bot_lib/reconcile.py | 28 ++++++++-- .../test_reconcile_workflow_run.py | 8 +-- tests/unit/reviewer_bot/test_commands.py | 12 +++-- 4 files changed, 44 insertions(+), 56 deletions(-) diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 3bbb99ad8..e0667876b 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -302,23 +302,8 @@ def build_pr_comment_observer_payload( def _record_conversation_freshness( bot, state: dict, - request_or_issue_number: CommentEventRequest | int, - comment_author: str | None = None, - comment_id: int | None = None, - created_at: str | None = None, + request: CommentEventRequest, ) -> bool: - if isinstance(request_or_issue_number, CommentEventRequest): - request = request_or_issue_number - else: - request = CommentEventRequest( - issue_number=request_or_issue_number, - is_pull_request=False, - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - comment_id=comment_id or 0, - comment_author=comment_author or "", - comment_created_at=created_at or "", - comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), - ) issue_number = request.issue_number review_data = bot.ensure_review_entry(state, issue_number, create=True) if review_data is None: @@ -372,17 +357,8 @@ def _store_pending_privileged_command(review_data: dict, issue_number: int, sour def _validate_accept_no_fls_changes_handoff( bot, - request_or_issue_number: CommentEventRequest | int, - comment_author: str | None = None, + request: CommentEventRequest, ) -> tuple[bool, dict]: - if isinstance(request_or_issue_number, CommentEventRequest): - request = request_or_issue_number - else: - request = CommentEventRequest( - issue_number=request_or_issue_number, - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - comment_author=comment_author or "", - ) if request.is_pull_request: return False, {"reason": "pull_request_target_not_allowed"} labels = bot.parse_issue_labels() @@ -405,29 +381,15 @@ def _validate_accept_no_fls_changes_handoff( def _handle_command( bot, state: dict, - request_or_issue_number: CommentEventRequest | int, - comment_author: str | dict | None = None, - classified: dict | None = None, + request: CommentEventRequest, + classified: dict, ) -> bool: - if isinstance(request_or_issue_number, CommentEventRequest): - request = request_or_issue_number - classified_payload = classified if classified is not None else comment_author - else: - request = CommentEventRequest( - issue_number=request_or_issue_number, - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), - comment_author=str(comment_author or ""), - comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), - ) - classified_payload = classified - if not isinstance(classified_payload, dict): + if not isinstance(classified, dict): return False issue_number = request.issue_number comment_author = request.comment_author - command = classified_payload.get("command") - args = classified_payload.get("args") or [] + command = classified.get("command") + args = classified.get("args") or [] if not isinstance(command, str): return False actor_class = _classify_issue_comment_actor(request) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 64a650947..640758c27 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -12,6 +12,7 @@ _record_conversation_freshness, classify_comment_payload, ) +from .context import CommentEventRequest from .reviews import ( find_triage_approval_after, refresh_reviewer_review_from_live_preferred_review, @@ -648,12 +649,31 @@ def _reconcile_deferred_comment( comment_author = context.actor_login comment_created_at = context.source_created_at source_freshness_eligible = context.source_freshness_eligible + + def replay_request(*, comment_body: str = "") -> CommentEventRequest: + return CommentEventRequest( + issue_number=pr_number, + is_pull_request=True, + issue_author=bot.get_config_value("ISSUE_AUTHOR"), + comment_id=comment_id, + comment_author=comment_author or "", + comment_body=comment_body, + comment_created_at=comment_created_at, + comment_source_event_key=context.source_event_key, + comment_user_type=bot.get_config_value("COMMENT_USER_TYPE"), + comment_sender_type=bot.get_config_value("COMMENT_SENDER_TYPE"), + comment_installation_id=bot.get_config_value("COMMENT_INSTALLATION_ID"), + comment_performed_via_github_app=( + bot.get_config_value("COMMENT_PERFORMED_VIA_GITHUB_APP").strip().lower() == "true" + ), + ) + try: live_comment = _read_reconcile_object(bot, context.live_comment_endpoint, label=f"deferred comment {comment_id}") except ReconcileReadError as exc: changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) + changed = _record_conversation_freshness(bot, state, replay_request()) if exc.failure_kind == "not_found": summary = ( f"Deferred comment {comment_id} is no longer visible; source-time freshness only may be preserved. " @@ -680,12 +700,12 @@ def _reconcile_deferred_comment( if _digest_body(live_body) != payload.get("source_body_digest"): changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) + changed = _record_conversation_freshness(bot, state, replay_request(comment_body=live_body)) gap_changed = _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} body digest changed; command execution suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") return changed or gap_changed changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, pr_number, comment_author, comment_id, comment_created_at) or changed + changed = _record_conversation_freshness(bot, state, replay_request(comment_body=live_body)) or changed validation_result = _validate_live_comment_replay_contract( bot, review_data, @@ -696,7 +716,7 @@ def _reconcile_deferred_comment( return changed or validation_result.changed live_classified = validation_result.live_classified if context.payload.comment_class in {"command_only", "command_plus_text"}: - changed = _handle_command(bot, state, pr_number, comment_author, live_classified) or changed + changed = _handle_command(bot, state, replay_request(comment_body=live_body), live_classified) or changed reconciled_changed = _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) gap_cleared_changed = _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) return changed or reconciled_changed or gap_cleared_changed diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index e07094a1c..8792b1e61 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -507,14 +507,14 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke monkeypatch.setattr( reviewer_bot.reconcile_module, "_handle_command", - lambda bot, state_obj, issue_number, comment_author, classified: claim_contexts.append( + lambda bot, state_obj, request, classified: claim_contexts.append( { - "issue_number": issue_number, - "username": comment_author, + "issue_number": request.issue_number, + "username": request.comment_author, "is_pull_request": harness.config.values.get("IS_PULL_REQUEST"), "issue_author": harness.config.values.get("ISSUE_AUTHOR"), } - ) or state_obj["active_reviews"][str(issue_number)].__setitem__("current_reviewer", comment_author) or True, + ) or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) or True, ) monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index 927d8d238..f7bfc48c8 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -4,6 +4,7 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing from tests.fixtures.commands_harness import CommandHarness +from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -286,14 +287,19 @@ def test_handle_rectify_command_reports_permission_denied(monkeypatch): def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "false") + harness = CommentRoutingHarness(monkeypatch) monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + request = harness.request( + issue_number=42, + is_pull_request=False, + comment_author="alice", + comment_body="@guidelines-bot /accept-no-fls-changes", + ) ok, metadata = comment_routing._validate_accept_no_fls_changes_handoff( reviewer_bot, - 42, - "alice", + request, ) assert ok is False From cc854300d13bbb699e15d1e8f0f3ba042a60e51a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:48:06 +0900 Subject: [PATCH 079/209] refactor(reviewer-bot): unify command automation seams --- scripts/reviewer_bot_lib/automation.py | 9 +- scripts/reviewer_bot_lib/commands.py | 131 ++----------------------- 2 files changed, 15 insertions(+), 125 deletions(-) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 5fc7da835..d7ff2b1bc 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -1,5 +1,6 @@ """Automation-heavy reviewer-bot helpers.""" +import json import os import subprocess from datetime import datetime, timezone @@ -43,6 +44,7 @@ def get_target_repo_root() -> Path: def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() + workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() return PrivilegedCommandRequest( issue_number=issue_number, actor=actor, @@ -50,14 +52,17 @@ def build_privileged_command_request(*, issue_number: int, actor: str = "", comm is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", issue_labels=tuple(bot_label for bot_label in bot_parse_issue_labels()), target_repo_root=target_repo_root, + workflow_run_reconcile_pr_number=( + int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None + ), + workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), + workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), ) def bot_parse_issue_labels() -> list[str]: labels_json = os.environ.get("ISSUE_LABELS", "[]") try: - import json - labels = json.loads(labels_json) except Exception: labels = [] diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 2d0aee214..325d47752 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -3,10 +3,14 @@ import json import os import re -import subprocess from datetime import datetime, timezone -from pathlib import Path +from .automation import ( + bot_parse_issue_labels, +) +from .automation import ( + build_privileged_command_request as automation_build_privileged_command_request, +) from .automation import ( find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status, ) @@ -37,20 +41,10 @@ def build_assignment_request(*, issue_number: int) -> AssignmentRequest: def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: - target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() - workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() - return PrivilegedCommandRequest( + return automation_build_privileged_command_request( issue_number=issue_number, actor=actor, command_name=command_name, - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=tuple(_parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]"))), - target_repo_root=target_repo_root, - workflow_run_reconcile_pr_number=( - int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None - ), - workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), - workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), ) @@ -342,40 +336,7 @@ def handle_label_command( def parse_issue_labels() -> list[str]: - return _parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]")) - - -def run_command(command: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: - result = subprocess.run(command, cwd=cwd, capture_output=True, text=True) - if check and result.returncode != 0: - raise RuntimeError((result.stderr or result.stdout or "Command failed").strip()) - return result - - -def summarize_output(result: subprocess.CompletedProcess, limit: int = 20) -> str: - combined = "\n".join([line for line in [result.stdout, result.stderr] if line]).strip() - if not combined: - return "" - lines = combined.splitlines() - return "\n".join(lines[-limit:]) - - -def list_changed_files(repo_root: Path) -> list[str]: - files: list[str] = [] - for command in (["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"]): - result = run_command(command, cwd=repo_root) - for line in result.stdout.splitlines(): - path = line.strip() - if path: - files.append(path) - return sorted(set(files)) - - -def get_target_repo_root() -> Path: - configured = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() - if configured: - return Path(configured) - return Path(__file__).resolve().parents[2] + return bot_parse_issue_labels() def get_default_branch(bot) -> str: @@ -428,82 +389,6 @@ def resolve_workflow_run_pr_number( return pr_number -def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None: - lookup_status, existing = automation_find_open_pr_for_branch_status(bot, branch) - if lookup_status == "found": - return existing - if lookup_status == "unavailable": - raise RuntimeError(f"Unable to determine whether branch '{branch}' already has an open PR") - title = "chore: update spec.lock (no guideline impact)" - body = "Updates `src/spec.lock` after confirming the audit reported no affected guidelines.\n\n" f"Closes #{issue_number}" - response = bot.github_api("POST", "pulls", {"title": title, "head": branch, "base": base, "body": body}) - if isinstance(response, dict): - return response - return None - - -def handle_accept_no_fls_changes_command( - bot, - issue_number: int, - comment_author: str, - request: PrivilegedCommandRequest | None = None, -) -> tuple[str, bool]: - privileged_request = request or build_privileged_command_request( - issue_number=issue_number, - actor=comment_author, - command_name="accept-no-fls-changes", - ) - if privileged_request.is_pull_request: - return "❌ This command can only be used on issues, not PRs.", False - labels = list(privileged_request.issue_labels) - if bot.FLS_AUDIT_LABEL not in labels: - return "❌ This command is only available on issues labeled `fls-audit`.", False - permission_status = bot.get_user_permission_status(comment_author, "triage") - if permission_status == "unavailable": - return "❌ Unable to verify triage permissions right now; refusing to run this command.", False - if permission_status != "granted": - return "❌ You must have triage permissions to run this command.", False - repo_root = get_target_repo_root() - if bot.list_changed_files(repo_root): - return "❌ Working tree is not clean; refusing to update spec.lock.", False - audit_result = bot.run_command(["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], cwd=repo_root, check=False) - if audit_result.returncode == 2: - return ("❌ The audit reports affected guidelines. Please review and open a PR with the necessary guideline updates instead."), False - if audit_result.returncode != 0: - details = bot.summarize_output(audit_result) - detail_text = f"\n\nDetails:\n```\n{details}\n```" if details else "" - return (f"❌ Audit command failed.{detail_text}"), False - update_result = bot.run_command(["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], cwd=repo_root, check=False) - if update_result.returncode != 0: - details = bot.summarize_output(update_result) - detail_text = f"\n\nDetails:\n```\n{details}\n```" if details else "" - return (f"❌ Failed to update spec.lock.{detail_text}"), False - changed_files = bot.list_changed_files(repo_root) - if not changed_files: - return "✅ `src/spec.lock` is already up to date; no PR needed.", True - unexpected = {path for path in changed_files if path != "src/spec.lock"} - if unexpected: - paths = ", ".join(sorted(unexpected)) - return (f"❌ Unexpected tracked file changes detected; refusing to open a PR. Please review: {paths}"), False - branch_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") - base_branch = bot.get_default_branch() - branch_name = f"chore/spec-lock-{branch_date}-issue-{issue_number}" - if bot.run_command(["git", "rev-parse", "--verify", branch_name], cwd=repo_root, check=False).returncode == 0: - suffix = datetime.now(timezone.utc).strftime("%H%M%S") - branch_name = f"{branch_name}-{suffix}" - try: - bot.run_command(["git", "checkout", "-b", branch_name], cwd=repo_root) - bot.run_command(["git", "add", "src/spec.lock"], cwd=repo_root) - bot.run_command(["git", "-c", "user.name=guidelines-bot", "-c", "user.email=guidelines-bot@users.noreply.github.com", "commit", "-m", "chore: update spec.lock; no affected guidelines"], cwd=repo_root) - bot.run_command(["git", "push", "origin", branch_name], cwd=repo_root) - except RuntimeError as exc: - return (f"❌ Failed to create branch or push changes: {exc}"), False - pr = bot.create_pull_request(branch_name, base_branch, issue_number) - if not pr or "html_url" not in pr: - return "❌ Failed to open a pull request for the spec.lock update.", False - return (f"✅ Opened PR {pr['html_url']}"), True - - def handle_sync_members_command(bot, state: dict) -> tuple[str, bool]: state, changes = bot.sync_members_with_queue(state) if changes: From 6a74cd3d885d36ff9af05b0ee1087e7029dca589 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:53:27 +0900 Subject: [PATCH 080/209] refactor(reviewer-bot): narrow app runtime protocols --- scripts/reviewer_bot_lib/app.py | 23 +++++++----- scripts/reviewer_bot_lib/context.py | 36 +++++++++++++++++++ .../reviewer_bot/test_adapter_contract.py | 17 +++++++++ tests/fixtures/fake_runtime.py | 33 +++++++++++++++++ 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 0f12e63b7..1cbb7a098 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -3,14 +3,19 @@ import json import sys -from .context import EventContext, ExecutionResult, ReviewerBotContext +from .context import ( + AppEventContextRuntime, + AppExecutionRuntime, + EventContext, + ExecutionResult, +) from .maintenance import ( collect_status_projection_repair_items, status_projection_repair_needed, ) -def _revalidate_epoch(bot: ReviewerBotContext, expected_epoch: str | None, phase: str) -> None: +def _revalidate_epoch(bot: AppExecutionRuntime, expected_epoch: str | None, phase: str) -> None: if expected_epoch is None: return latest_state = bot.load_state(fail_on_unavailable=True) @@ -21,7 +26,7 @@ def _revalidate_epoch(bot: ReviewerBotContext, expected_epoch: str | None, phase ) -def _mark_projection_repair_needed(bot: ReviewerBotContext, state: dict, issue_numbers: list[int], reason: str) -> bool: +def _mark_projection_repair_needed(bot: AppExecutionRuntime, state: dict, issue_numbers: list[int], reason: str) -> bool: changed = False active_reviews = state.get("active_reviews") if not isinstance(active_reviews, dict): @@ -81,7 +86,7 @@ def _parse_issue_labels(value: str) -> tuple[str, ...]: return tuple(label for label in payload if isinstance(label, str)) -def build_event_context(bot: ReviewerBotContext) -> EventContext: +def build_event_context(bot: AppEventContextRuntime) -> EventContext: return EventContext( event_name=bot.get_config_value("EVENT_NAME").strip(), event_action=bot.get_config_value("EVENT_ACTION").strip(), @@ -111,7 +116,7 @@ def build_event_context(bot: ReviewerBotContext) -> EventContext: ) -def _classify_event_intent_from_context(bot: ReviewerBotContext, context: EventContext) -> str: +def _classify_event_intent_from_context(bot: AppEventContextRuntime, context: EventContext) -> str: event_name = context.event_name event_action = context.event_action @@ -157,7 +162,7 @@ def _classify_event_intent_from_context(bot: ReviewerBotContext, context: EventC return bot.EVENT_INTENT_NON_MUTATING_READONLY -def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action: str) -> str: +def classify_event_intent(bot: AppEventContextRuntime, event_name: str, event_action: str) -> str: """Classify whether a run can mutate reviewer-bot state.""" context = build_event_context(bot) context = EventContext( @@ -188,12 +193,12 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action return _classify_event_intent_from_context(bot, context) -def event_requires_lease_lock(bot: ReviewerBotContext, event_name: str, event_action: str) -> bool: +def event_requires_lease_lock(bot: AppEventContextRuntime, event_name: str, event_action: str) -> bool: """Backwards-compatible helper for tests and call sites.""" return classify_event_intent(bot, event_name, event_action) == bot.EVENT_INTENT_MUTATING -def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResult: +def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionResult: bot.drain_touched_items() event_name = context.event_name @@ -397,7 +402,7 @@ def execute_run(bot: ReviewerBotContext, context: EventContext) -> ExecutionResu ) -def main(bot: ReviewerBotContext): +def main(bot: AppExecutionRuntime): """Main entry point for the reviewer bot.""" result = execute_run(bot, build_event_context(bot)) if result.exit_code: diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 90e6e77f1..b0b6e332a 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -96,6 +96,42 @@ class PrivilegedCommandRequest: workflow_run_head_sha: str = "" +@runtime_checkable +class AppEventContextRuntime(Protocol): + EVENT_INTENT_MUTATING: str + EVENT_INTENT_NON_MUTATING_DEFER: str + EVENT_INTENT_NON_MUTATING_READONLY: str + + def get_config_value(self, name: str, default: str = "") -> str: ... + + +@runtime_checkable +class AppExecutionRuntime(AppEventContextRuntime, Protocol): + datetime: type[datetime] + timezone: Any + + def write_output(self, name: str, value: str) -> None: ... + def drain_touched_items(self) -> list[int]: ... + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: ... + def save_state(self, state: dict) -> bool: ... + def ensure_state_issue_lease_lock_fresh(self) -> bool: ... + def acquire_state_issue_lease_lock(self) -> LeaseContext: ... + def release_state_issue_lease_lock(self) -> bool: ... + def process_pass_until_expirations(self, state: dict) -> tuple[dict, list[str]]: ... + def sync_members_with_queue(self, state: dict) -> tuple[dict, list[str]]: ... + def handle_issue_or_pr_opened(self, state: dict) -> bool: ... + def handle_labeled_event(self, state: dict) -> bool: ... + def handle_issue_edited_event(self, state: dict) -> bool: ... + def handle_closed_event(self, state: dict) -> bool: ... + def handle_pull_request_target_synchronize(self, state: dict) -> bool: ... + def handle_pull_request_review_event(self, state: dict) -> bool: ... + def handle_comment_event(self, state: dict) -> bool: ... + def handle_manual_dispatch(self, state: dict) -> bool: ... + def handle_scheduled_check(self, state: dict) -> bool: ... + def handle_workflow_run_event(self, state: dict) -> bool: ... + def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int]) -> bool: ... + + @runtime_checkable class GitHubTransportContext(Protocol): """GitHub API transport and mutation surface used by low-level helpers.""" diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 989c6a015..f393184c6 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -1,16 +1,21 @@ from typing import get_type_hints + import pytest pytestmark = pytest.mark.contract from scripts import reviewer_bot from scripts.reviewer_bot_lib.context import ( + AppEventContextRuntime, + AppExecutionRuntime, GitHubTransportContext, LeaseLockContext, ReviewerBotContext, StateStoreContext, ) from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime + def test_reviewer_bot_exports_runtime_modules(): assert reviewer_bot.requests is not None @@ -26,6 +31,11 @@ def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): assert isinstance(reviewer_bot, StateStoreContext) assert isinstance(reviewer_bot, LeaseLockContext) + +def test_reviewer_bot_satisfies_app_specific_runtime_protocols(): + assert isinstance(reviewer_bot, AppEventContextRuntime) + assert isinstance(reviewer_bot, AppExecutionRuntime) + def test_render_lock_commit_message_uses_direct_json_import(): rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) assert reviewer_bot.LOCK_COMMIT_MARKER in rendered @@ -46,6 +56,13 @@ def test_runtime_bot_returns_concrete_runtime_object(): assert isinstance(runtime, ReviewerBotRuntime) assert runtime.EVENT_INTENT_MUTATING == reviewer_bot.EVENT_INTENT_MUTATING + +def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert isinstance(runtime, AppEventContextRuntime) + assert isinstance(runtime, AppExecutionRuntime) + def test_build_event_context_returns_structured_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index bb9901a44..c19640ee7 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -74,6 +74,9 @@ def load_state(self, *, fail_on_unavailable: bool = False) -> dict: def save_state(self, state: dict) -> bool: return self._save_state_impl(state) + def ensure_state_issue_lease_lock_fresh(self) -> bool: + return self._module.ensure_state_issue_lease_lock_fresh() + def acquire_state_issue_lease_lock(self): return self._acquire_impl() @@ -89,6 +92,36 @@ def sync_members_with_queue(self, state: dict): def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._sync_status_labels_impl(state, issue_numbers) + def handle_issue_or_pr_opened(self, state: dict) -> bool: + return self._module.handle_issue_or_pr_opened(state) + + def handle_labeled_event(self, state: dict) -> bool: + return self._module.handle_labeled_event(state) + + def handle_issue_edited_event(self, state: dict) -> bool: + return self._module.handle_issue_edited_event(state) + + def handle_closed_event(self, state: dict) -> bool: + return self._module.handle_closed_event(state) + + def handle_pull_request_target_synchronize(self, state: dict) -> bool: + return self._module.handle_pull_request_target_synchronize(state) + + def handle_pull_request_review_event(self, state: dict) -> bool: + return self._module.handle_pull_request_review_event(state) + + def handle_comment_event(self, state: dict) -> bool: + return self._module.handle_comment_event(state) + + def handle_manual_dispatch(self, state: dict) -> bool: + return self._module.handle_manual_dispatch(state) + + def handle_scheduled_check(self, state: dict) -> bool: + return self._module.handle_scheduled_check(state) + + def handle_workflow_run_event(self, state: dict) -> bool: + return self._module.handle_workflow_run_event(state) + def github_api(self, method: str, endpoint: str, data=None): if self._github is not None: return self._github.github_api(method, endpoint, data=data) From 5a93222af9e099f0df88f81f6a643aaf44669951 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Thu, 2 Apr 2026 12:59:10 +0900 Subject: [PATCH 081/209] refactor(reviewer-bot): type reconcile comment replay --- scripts/reviewer_bot_lib/reconcile.py | 75 ++++++++++--------- .../test_reconcile_workflow_run.py | 9 +-- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 640758c27..d3f187c01 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from copy import deepcopy from dataclasses import dataclass @@ -48,6 +47,22 @@ class DeferredArtifactIdentity: source_event_key: str +@dataclass(frozen=True) +class LivePrReplayContext: + issue_author: str + issue_labels: tuple[str, ...] + + +@dataclass(frozen=True) +class LiveCommentReplayContext: + comment_author: str + comment_user_type: str + comment_author_association: str + comment_sender_type: str + comment_installation_id: str + comment_performed_via_github_app: bool + + @dataclass(frozen=True) class DeferredReviewPayload: identity: DeferredArtifactIdentity @@ -473,13 +488,7 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def raise RuntimeError("Unsupported deferred workflow_run payload") -def _set_config_if_present(bot, name: str, value) -> None: - if value is None: - return - bot.set_config_value(name, value) - - -def _hydrate_reconcile_pr_context(bot, pr_number: int) -> dict: +def _read_live_pr_replay_context(bot, pr_number: int) -> LivePrReplayContext: pull_request = _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number} for reconcile context") author = pull_request.get("user") if not isinstance(author, dict): @@ -500,13 +509,10 @@ def _hydrate_reconcile_pr_context(bot, pr_number: int) -> dict: if not isinstance(name, str): raise RuntimeError(f"Live PR #{pr_number} contains a label without a valid name") label_names.append(name) - bot.set_config_value("IS_PULL_REQUEST", "true") - bot.set_config_value("ISSUE_AUTHOR", author_login) - bot.set_config_value("ISSUE_LABELS", json.dumps(label_names)) - return pull_request + return LivePrReplayContext(issue_author=author_login, issue_labels=tuple(label_names)) -def _hydrate_reconcile_comment_context(bot, live_comment: dict, payload: dict) -> None: +def _read_live_comment_replay_context(live_comment: dict, payload: dict) -> LiveCommentReplayContext: user = live_comment.get("user") if not isinstance(user, dict): raise RuntimeError("Live deferred comment user metadata is unavailable") @@ -519,17 +525,13 @@ def _hydrate_reconcile_comment_context(bot, live_comment: dict, payload: dict) - author_association = live_comment.get("author_association") if not isinstance(author_association, str) or not author_association.strip(): raise RuntimeError("Live deferred comment author association is unavailable") - _set_config_if_present(bot, "COMMENT_AUTHOR", comment_author) - _set_config_if_present(bot, "COMMENT_ID", payload.get("comment_id")) - _set_config_if_present(bot, "COMMENT_SOURCE_EVENT_KEY", payload.get("source_event_key")) - _set_config_if_present(bot, "COMMENT_CREATED_AT", payload.get("source_created_at")) - _set_config_if_present(bot, "COMMENT_USER_TYPE", comment_user_type) - _set_config_if_present(bot, "COMMENT_AUTHOR_ASSOCIATION", author_association) - _set_config_if_present(bot, "COMMENT_SENDER_TYPE", comment_user_type) - bot.set_config_value("COMMENT_INSTALLATION_ID", "") - bot.set_config_value( - "COMMENT_PERFORMED_VIA_GITHUB_APP", - "true" if live_comment.get("performed_via_github_app") else "false", + return LiveCommentReplayContext( + comment_author=comment_author, + comment_user_type=comment_user_type, + comment_author_association=author_association, + comment_sender_type=comment_user_type, + comment_installation_id="", + comment_performed_via_github_app=bool(live_comment.get("performed_via_github_app")), ) @@ -644,27 +646,26 @@ def _reconcile_deferred_comment( payload = context.payload.raw_payload comment_id = context.comment_id pr_number = context.pr_number - _set_config_if_present(bot, "COMMENT_SOURCE_EVENT_KEY", context.source_event_key) - _hydrate_reconcile_pr_context(bot, pr_number) + pr_context = _read_live_pr_replay_context(bot, pr_number) comment_author = context.actor_login comment_created_at = context.source_created_at source_freshness_eligible = context.source_freshness_eligible - def replay_request(*, comment_body: str = "") -> CommentEventRequest: + def replay_request(comment_context: LiveCommentReplayContext | None = None, *, comment_body: str = "") -> CommentEventRequest: return CommentEventRequest( issue_number=pr_number, is_pull_request=True, - issue_author=bot.get_config_value("ISSUE_AUTHOR"), + issue_author=pr_context.issue_author, comment_id=comment_id, - comment_author=comment_author or "", + comment_author=(comment_context.comment_author if comment_context is not None else (comment_author or "")), comment_body=comment_body, comment_created_at=comment_created_at, comment_source_event_key=context.source_event_key, - comment_user_type=bot.get_config_value("COMMENT_USER_TYPE"), - comment_sender_type=bot.get_config_value("COMMENT_SENDER_TYPE"), - comment_installation_id=bot.get_config_value("COMMENT_INSTALLATION_ID"), + comment_user_type=(comment_context.comment_user_type if comment_context is not None else ""), + comment_sender_type=(comment_context.comment_sender_type if comment_context is not None else ""), + comment_installation_id=(comment_context.comment_installation_id if comment_context is not None else ""), comment_performed_via_github_app=( - bot.get_config_value("COMMENT_PERFORMED_VIA_GITHUB_APP").strip().lower() == "true" + comment_context.comment_performed_via_github_app if comment_context is not None else False ), ) @@ -693,19 +694,19 @@ def replay_request(*, comment_body: str = "") -> CommentEventRequest: failure_kind=exc.failure_kind, ) return changed or gap_changed - _hydrate_reconcile_comment_context(bot, live_comment, payload) + comment_context = _read_live_comment_replay_context(live_comment, payload) live_body = live_comment.get("body") if not isinstance(live_body, str): raise RuntimeError("Live deferred comment body is unavailable") if _digest_body(live_body) != payload.get("source_body_digest"): changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, replay_request(comment_body=live_body)) + changed = _record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) gap_changed = _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} body digest changed; command execution suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") return changed or gap_changed changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, replay_request(comment_body=live_body)) or changed + changed = _record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) or changed validation_result = _validate_live_comment_replay_contract( bot, review_data, @@ -716,7 +717,7 @@ def replay_request(*, comment_body: str = "") -> CommentEventRequest: return changed or validation_result.changed live_classified = validation_result.live_classified if context.payload.comment_class in {"command_only", "command_plus_text"}: - changed = _handle_command(bot, state, replay_request(comment_body=live_body), live_classified) or changed + changed = _handle_command(bot, state, replay_request(comment_context, comment_body=live_body), live_classified) or changed reconciled_changed = _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) gap_cleared_changed = _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) return changed or reconciled_changed or gap_cleared_changed diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 8792b1e61..1394802cc 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -464,9 +464,6 @@ def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_f assert harness.run(state) is True assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None - assert harness.config.values["IS_PULL_REQUEST"] == "true" - assert harness.config.values["ISSUE_AUTHOR"] == "dana" - assert harness.config.values["ISSUE_LABELS"] == '["coding guideline"]' def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monkeypatch): @@ -511,8 +508,8 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke { "issue_number": request.issue_number, "username": request.comment_author, - "is_pull_request": harness.config.values.get("IS_PULL_REQUEST"), - "issue_author": harness.config.values.get("ISSUE_AUTHOR"), + "is_pull_request": request.is_pull_request, + "issue_author": request.issue_author, } ) or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) or True, ) @@ -523,7 +520,7 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke { "issue_number": 42, "username": "bob", - "is_pull_request": "true", + "is_pull_request": True, "issue_author": "dana", } ] From 7ee557bc3447c75f82d1b1ddabff2b160b0cbc88 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 03:49:42 +0900 Subject: [PATCH 082/209] test(reviewer-bot): canonicalize fake runtime usage --- .../test_fake_runtime_contract.py | 23 +- tests/fixtures/app_harness.py | 21 + tests/fixtures/commands_harness.py | 17 +- tests/fixtures/comment_routing_harness.py | 12 +- tests/fixtures/fake_runtime.py | 447 +++++++++++++++--- tests/fixtures/reconcile_harness.py | 49 +- .../test_app_closed_issue_cleanup.py | 26 +- .../reviewer_bot/test_app_execution.py | 39 +- .../test_app_reviewer_board_preview.py | 52 +- .../test_app_state_unavailable.py | 14 +- .../test_app_workflow_run_paths.py | 38 +- .../test_reconcile_workflow_run.py | 16 +- tests/unit/reviewer_bot/test_commands.py | 74 ++- 13 files changed, 556 insertions(+), 272 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index b7567cd89..9f4227b36 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -12,6 +12,8 @@ def test_fake_runtime_config_writes_mirror_env(monkeypatch): runtime.set_config_value("EVENT_NAME", "issue_comment") assert runtime.get_config_value("EVENT_NAME") == "issue_comment" + assert hasattr(FakeReviewerBotRuntime, "__getattr__") is False + assert "_module" not in vars(runtime) def test_fake_runtime_output_sink_records_writes(monkeypatch): @@ -69,14 +71,31 @@ def test_fake_runtime_record_saves_captures_structured_snapshots(monkeypatch): def test_fake_runtime_optional_lock_hooks_are_replaceable(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) calls = [] - runtime.set_acquire_lock(lambda: calls.append("acquire") or None) - runtime.set_release_lock(lambda: calls.append("release") or True) + runtime.stub_lock(acquire=lambda: calls.append("acquire") or None, release=lambda: calls.append("release") or True) assert runtime.acquire_state_issue_lease_lock() is None assert runtime.release_state_issue_lease_lock() is True assert calls == ["acquire", "release"] +def test_fake_runtime_uses_explicit_public_service_fields(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert runtime.config is not None + assert runtime.outputs is not None + assert runtime.deferred_payloads is not None + assert runtime.state_store is not None + assert runtime.github is not None + assert runtime.locks is not None + + +def test_fake_runtime_rejects_unknown_handler_names(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + with pytest.raises(AssertionError, match="Unsupported runtime handler override"): + runtime.stub_handler("handle_everything", lambda state: False) + + def test_fake_runtime_github_transport_delegates_to_shared_route_fake(monkeypatch): github = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload={"head": {"sha": "head-1"}}) runtime = FakeReviewerBotRuntime(monkeypatch, github=github) diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index 51b1eb3e6..d3d6affc0 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -28,6 +28,27 @@ def set_event(self, **values) -> None: def set_state_sequence(self, *states: dict) -> None: self.runtime.stub_state_sequence(*states) + def stub_load_state(self, func) -> None: + self.runtime.stub_load_state(func) + + def stub_save_state(self, func) -> None: + self.runtime.stub_save_state(func) + + def stub_lock(self, *, acquire=None, release=None, refresh=None) -> None: + self.runtime.stub_lock(acquire=acquire, release=release, refresh=refresh) + + def stub_handler(self, name: str, func) -> None: + self.runtime.stub_handler(name, func) + + def stub_pass_until(self, func) -> None: + self.runtime.stub_pass_until(func) + + def stub_sync_members(self, func) -> None: + self.runtime.stub_sync_members(func) + + def stub_sync_status_labels(self, func) -> None: + self.runtime.stub_sync_status_labels(func) + def stub_execute_run(self, result: reviewer_bot.ExecutionResult) -> MainRun: captured = MainRun(exit_code=None) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index 9ffaa1634..4887d177b 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -87,24 +87,25 @@ def set_manual_dispatch(self, *, source_event_key: str) -> None: def record_comments(self): posted = [] - self._monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + self.runtime.post_comment = lambda issue_number, body: posted.append((issue_number, body)) or True return posted def stub_assignees(self, assignees): - self._monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: assignees) + self.runtime.get_issue_assignees = lambda issue_number: assignees def stub_assignment(self, *, success: bool = True, status_code: int = 201): - self._monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda issue_number, username: reviewer_bot.AssignmentAttempt(success=success, status_code=status_code), + self.runtime.request_reviewer_assignment = lambda issue_number, username: reviewer_bot.AssignmentAttempt( + success=success, status_code=status_code ) def stub_permission(self, status: str) -> None: - self._monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": status) + self.runtime.get_user_permission_status = lambda username, required_permission="triage": status + + def stub_handler(self, name: str, func) -> None: + self.runtime.stub_handler(name, func) def automation_runner(self) -> AutomationRunner: runner = AutomationRunner() - self._monkeypatch.setattr(reviewer_bot, "run_command", runner.run) self._monkeypatch.setattr(reviewer_bot.automation_module, "run_command", runner.run) + self.runtime.run_command = runner.run return runner diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index f19922b3b..8b9a07689 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -90,16 +90,8 @@ def add_pull_request_metadata( def side_effects(self) -> SideEffects: comments: list[tuple[int, str]] = [] reactions: list[tuple[int, str]] = [] - self._monkeypatch.setattr( - reviewer_bot, - "post_comment", - lambda issue_number, body: comments.append((issue_number, body)) or True, - ) - self._monkeypatch.setattr( - reviewer_bot, - "add_reaction", - lambda comment_id, reaction: reactions.append((comment_id, reaction)) or True, - ) + self.runtime.post_comment = lambda issue_number, body: comments.append((issue_number, body)) or True + self.runtime.add_reaction = lambda comment_id, reaction: reactions.append((comment_id, reaction)) or True return SideEffects(comments=comments, reactions=reactions) def set_wrapper_env( diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index c19640ee7..fe77256a8 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -2,9 +2,32 @@ import json from copy import deepcopy -from typing import Any - -from scripts import reviewer_bot +from datetime import datetime, timezone +from typing import Any, Callable + +from scripts.reviewer_bot_lib import automation as automation_module +from scripts.reviewer_bot_lib import commands as commands_module +from scripts.reviewer_bot_lib import comment_routing as comment_routing_module +from scripts.reviewer_bot_lib import github_api as github_api_module +from scripts.reviewer_bot_lib import lifecycle as lifecycle_module +from scripts.reviewer_bot_lib import maintenance as maintenance_module +from scripts.reviewer_bot_lib import queue as queue_module +from scripts.reviewer_bot_lib import reconcile as reconcile_module +from scripts.reviewer_bot_lib import reviews as reviews_module +from scripts.reviewer_bot_lib import state_store as state_store_module +from scripts.reviewer_bot_lib.config import ( + AUTHOR_ASSOCIATION_TRUST_ALLOWLIST, + BOT_MENTION, + BOT_NAME, + EVENT_INTENT_MUTATING, + EVENT_INTENT_NON_MUTATING_DEFER, + EVENT_INTENT_NON_MUTATING_READONLY, + FLS_AUDIT_LABEL, + REVIEW_FRESHNESS_RUNBOOK_PATH, + REVIEWER_REQUEST_422_TEMPLATE, + STATUS_PROJECTION_EPOCH, + AssignmentAttempt, +) class ConfigBag: @@ -29,35 +52,157 @@ def write(self, name: str, value: str) -> None: self.writes.append((name, value)) +class DeferredPayloadStore: + def __init__(self): + self._payload: dict = {} + + def set_payload(self, payload: dict) -> None: + self._payload = payload + + def load(self) -> dict: + return self._payload + + +class StateStoreStub: + def __init__(self): + self._load: Callable[..., dict] = lambda *, fail_on_unavailable=False: {"active_reviews": {}} + self._save: Callable[[dict], bool] = lambda state: True + + def stub_load(self, func: Callable[..., dict]) -> None: + self._load = func + + def stub_save(self, func: Callable[[dict], bool]) -> None: + self._save = func + + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + return self._load(fail_on_unavailable=fail_on_unavailable) + + def save_state(self, state: dict) -> bool: + return self._save(state) + + +class LockStub: + def __init__(self): + self._acquire: Callable[[], Any] = lambda: None + self._release: Callable[[], bool] = lambda: True + self._refresh: Callable[[], bool] = lambda: True + + def stub(self, *, acquire=None, release=None, refresh=None) -> None: + if acquire is not None: + self._acquire = acquire + if release is not None: + self._release = release + if refresh is not None: + self._refresh = refresh + + def acquire(self): + return self._acquire() + + def release(self) -> bool: + return self._release() + + def refresh(self) -> bool: + return self._refresh() + + +class GitHubStub: + def __init__(self, github=None): + self._github = github + + def stub(self, github) -> None: + self._github = github + + def github_api(self, method: str, endpoint: str, data=None): + if self._github is None: + raise AssertionError(f"No GitHub stub configured for {method} {endpoint}") + return self._github.github_api(method, endpoint, data=data) + + def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): + if self._github is None: + raise AssertionError(f"No GitHub request stub configured for {method} {endpoint}") + return self._github.github_api_request( + method, + endpoint, + data=data, + extra_headers=extra_headers, + **kwargs, + ) + + +class HandlerStub: + ALLOWED = { + "handle_issue_or_pr_opened", + "handle_labeled_event", + "handle_issue_edited_event", + "handle_closed_event", + "handle_pull_request_target_synchronize", + "handle_pull_request_review_event", + "handle_comment_event", + "handle_manual_dispatch", + "handle_scheduled_check", + "handle_workflow_run_event", + } + + def __init__(self, defaults: dict[str, Callable[[dict], bool]]): + self._handlers: dict[str, Callable[[dict], bool]] = defaults + + @staticmethod + def _missing_handler(name: str) -> Callable[[dict], bool]: + def fail(_state: dict) -> bool: + raise AssertionError(f"No handler stub configured for {name}") + + return fail + + def stub(self, name: str, func: Callable[[dict], bool]) -> None: + if name not in self.ALLOWED: + raise AssertionError(f"Unsupported runtime handler override: {name}") + self._handlers[name] = func + + def call(self, name: str, state: dict) -> bool: + return self._handlers[name](state) + + class FakeReviewerBotRuntime: - EVENT_INTENT_MUTATING = reviewer_bot.EVENT_INTENT_MUTATING - EVENT_INTENT_NON_MUTATING_DEFER = reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER - EVENT_INTENT_NON_MUTATING_READONLY = reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY - STATUS_PROJECTION_EPOCH = reviewer_bot.STATUS_PROJECTION_EPOCH - datetime = reviewer_bot.datetime - timezone = reviewer_bot.timezone + BOT_NAME = BOT_NAME + BOT_MENTION = BOT_MENTION + FLS_AUDIT_LABEL = FLS_AUDIT_LABEL + AUTHOR_ASSOCIATION_TRUST_ALLOWLIST = AUTHOR_ASSOCIATION_TRUST_ALLOWLIST + REVIEWER_REQUEST_422_TEMPLATE = REVIEWER_REQUEST_422_TEMPLATE + REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH + AssignmentAttempt = AssignmentAttempt + reviews_module = reviews_module + EVENT_INTENT_MUTATING = EVENT_INTENT_MUTATING + EVENT_INTENT_NON_MUTATING_DEFER = EVENT_INTENT_NON_MUTATING_DEFER + EVENT_INTENT_NON_MUTATING_READONLY = EVENT_INTENT_NON_MUTATING_READONLY + STATUS_PROJECTION_EPOCH = STATUS_PROJECTION_EPOCH + datetime = datetime + timezone = timezone def __init__(self, monkeypatch, *, github=None): - self._monkeypatch = monkeypatch - self._module = reviewer_bot - self._github = github self.config = ConfigBag(monkeypatch) self.outputs = OutputCapture() - self._load_state_impl = lambda *, fail_on_unavailable=False: reviewer_bot.load_state( - fail_on_unavailable=fail_on_unavailable - ) - self._save_state_impl = lambda state: reviewer_bot.save_state(state) - self._acquire_impl = lambda: reviewer_bot.acquire_state_issue_lease_lock() - self._release_impl = lambda: reviewer_bot.release_state_issue_lease_lock() - self._process_pass_until_impl = lambda state: reviewer_bot.process_pass_until_expirations(state) - self._sync_members_impl = lambda state: reviewer_bot.sync_members_with_queue(state) - self._sync_status_labels_impl = lambda state, issue_numbers: reviewer_bot.sync_status_labels_for_items( - state, issue_numbers + self.deferred_payloads = DeferredPayloadStore() + self.state_store = StateStoreStub() + self.locks = LockStub() + self.github = GitHubStub(github) + self.handlers = HandlerStub( + { + "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), + "handle_labeled_event": lambda state: lifecycle_module.handle_labeled_event(self, state), + "handle_issue_edited_event": lambda state: lifecycle_module.handle_issue_edited_event(self, state), + "handle_closed_event": lambda state: lifecycle_module.handle_closed_event(self, state), + "handle_pull_request_target_synchronize": lambda state: lifecycle_module.handle_pull_request_target_synchronize(self, state), + "handle_pull_request_review_event": lambda state: lifecycle_module.handle_pull_request_review_event(self, state), + "handle_comment_event": lambda state: comment_routing_module.handle_comment_event(self, state), + "handle_manual_dispatch": lambda state: maintenance_module.handle_manual_dispatch(self, state), + "handle_scheduled_check": lambda state: maintenance_module.handle_scheduled_check(self, state), + "handle_workflow_run_event": lambda state: reconcile_module.handle_workflow_run_event(self, state), + } ) self._touched_items: list[int] = [] - - def __getattr__(self, name: str): - return getattr(self._module, name) + self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) + self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) + self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False def get_config_value(self, name: str, default: str = "") -> str: return self.config.get(name, default) @@ -68,75 +213,220 @@ def set_config_value(self, name: str, value: Any) -> None: def write_output(self, name: str, value: str) -> None: self.outputs.write(name, value) + def assert_lock_held(self, _context: str) -> None: + return None + + def load_deferred_payload(self) -> dict: + return self.deferred_payloads.load() + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: - return self._load_state_impl(fail_on_unavailable=fail_on_unavailable) + return self.state_store.load_state(fail_on_unavailable=fail_on_unavailable) def save_state(self, state: dict) -> bool: - return self._save_state_impl(state) + return self.state_store.save_state(state) def ensure_state_issue_lease_lock_fresh(self) -> bool: - return self._module.ensure_state_issue_lease_lock_fresh() + return self.locks.refresh() def acquire_state_issue_lease_lock(self): - return self._acquire_impl() + return self.locks.acquire() def release_state_issue_lease_lock(self) -> bool: - return self._release_impl() + return self.locks.release() def process_pass_until_expirations(self, state: dict): - return self._process_pass_until_impl(state) + return self._process_pass_until(state) def sync_members_with_queue(self, state: dict): - return self._sync_members_impl(state) + return self._sync_members(state) def sync_status_labels_for_items(self, state: dict, issue_numbers): - return self._sync_status_labels_impl(state, issue_numbers) + return self._sync_status_labels(state, issue_numbers) + + def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): + return reviews_module.ensure_review_entry(state, issue_number, create=create) + + def get_issue_assignees(self, issue_number: int): + return github_api_module.get_issue_assignees(self, issue_number) + + def request_reviewer_assignment(self, issue_number: int, username: str): + return github_api_module.request_reviewer_assignment(self, issue_number, username) + + def get_assignment_failure_comment(self, reviewer: str, attempt): + return github_api_module.get_assignment_failure_comment(self, reviewer, attempt) + + def post_comment(self, issue_number: int, body: str) -> bool: + return github_api_module.post_comment(self, issue_number, body) + + def add_reaction(self, comment_id: int, reaction: str) -> bool: + return github_api_module.add_reaction(self, comment_id, reaction) + + def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: + return github_api_module.get_user_permission_status(self, username, required_permission) + + def check_user_permission(self, username: str, required_permission: str = "triage"): + return github_api_module.check_user_permission(self, username, required_permission) + + def get_repo_labels(self): + return github_api_module.get_repo_labels(self) + + def add_label(self, issue_number: int, label: str) -> bool: + return github_api_module.add_label(self, issue_number, label) + + def remove_label(self, issue_number: int, label: str) -> bool: + return github_api_module.remove_label(self, issue_number, label) + + def remove_assignee(self, issue_number: int, username: str) -> bool: + return github_api_module.remove_assignee(self, issue_number, username) + + def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: + return github_api_module.remove_pr_reviewer(self, issue_number, username) + + def unassign_reviewer(self, issue_number: int, username: str) -> bool: + return github_api_module.unassign_reviewer(self, issue_number, username) + + def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: + payload = self.github_api("GET", f"issues/{issue_number}") + return payload if isinstance(payload, dict) else None + + def get_pull_request_reviews(self, issue_number: int): + return reviews_module.get_pull_request_reviews(self, issue_number) + + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): + return lifecycle_module.maybe_record_head_observation_repair(self, issue_number, review_data) + + def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str | None = None) -> None: + return reviews_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + + def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: + return reviews_module.mark_review_complete(state, issue_number, reviewer, source) + + def get_next_reviewer(self, state: dict, skip_usernames=None): + return queue_module.get_next_reviewer(state, skip_usernames) + + def strip_code_blocks(self, comment_body: str) -> str: + return commands_module.strip_code_blocks(comment_body) + + def parse_command(self, comment_body: str): + return commands_module.parse_command(self, comment_body) + + def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: + return queue_module.record_assignment(state, github, issue_number, kind) + + def reposition_member_as_next(self, state: dict, username: str) -> bool: + return queue_module.reposition_member_as_next(state, username) + + def parse_iso8601_timestamp(self, value: Any): + return state_store_module.parse_iso8601_timestamp(value) + + def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): + return reviews_module.compute_reviewer_response_state(self, issue_number, state, issue_snapshot=issue_snapshot) + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: + if prefer_board_token: + token = self.get_config_value("REVIEWER_BOARD_TOKEN") + if token: + return token + token = self.get_config_value("GITHUB_GRAPHQL_TOKEN") or self.get_config_value("GITHUB_TOKEN") + if token: + return token + raise RuntimeError("REVIEWER_BOARD_TOKEN not set") + + def github_graphql(self, query: str, variables=None, *, token=None): + return github_api_module.github_graphql(self, query, variables, token=token) + + def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: + return lifecycle_module.handle_transition_notice(self, state, issue_number, reviewer) + + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): + return commands_module.handle_pass_command(self, state, issue_number, comment_author, reason) + + def handle_pass_until_command(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None): + return commands_module.handle_pass_until_command(self, state, issue_number, comment_author, return_date, reason) + + def handle_label_command(self, state: dict, issue_number: int, label_string: str): + return commands_module.handle_label_command(self, state, issue_number, label_string) + + def handle_sync_members_command(self, state: dict): + return commands_module.handle_sync_members_command(self, state) + + def handle_queue_command(self, state: dict): + return commands_module.handle_queue_command(self, state) + + def handle_commands_command(self): + return commands_module.handle_commands_command(self) + + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): + return commands_module.handle_claim_command(self, state, issue_number, comment_author) + + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): + return commands_module.handle_release_command(self, state, issue_number, comment_author, args) + + def handle_assign_command(self, state: dict, issue_number: int, username: str): + return commands_module.handle_assign_command(self, state, issue_number, username) + + def handle_assign_from_queue_command(self, state: dict, issue_number: int): + return commands_module.handle_assign_from_queue_command(self, state, issue_number) + + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): + return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author) + + def parse_issue_labels(self) -> list[str]: + return automation_module.bot_parse_issue_labels() + + def run_command(self, command, cwd, check=False): + return automation_module.run_command(command, cwd=cwd, check=check) + + def summarize_output(self, result, limit: int = 20) -> str: + return automation_module.summarize_output(result, limit=limit) + + def list_changed_files(self, repo_root): + return automation_module.list_changed_files(repo_root) + + def get_default_branch(self) -> str: + return automation_module.get_default_branch(self) + + def find_open_pr_for_branch_status(self, branch: str): + return automation_module.find_open_pr_for_branch_status(self, branch) + + def create_pull_request(self, branch: str, base: str, issue_number: int): + return automation_module.create_pull_request(self, branch, base, issue_number) def handle_issue_or_pr_opened(self, state: dict) -> bool: - return self._module.handle_issue_or_pr_opened(state) + return self.handlers.call("handle_issue_or_pr_opened", state) def handle_labeled_event(self, state: dict) -> bool: - return self._module.handle_labeled_event(state) + return self.handlers.call("handle_labeled_event", state) def handle_issue_edited_event(self, state: dict) -> bool: - return self._module.handle_issue_edited_event(state) + return self.handlers.call("handle_issue_edited_event", state) def handle_closed_event(self, state: dict) -> bool: - return self._module.handle_closed_event(state) + return self.handlers.call("handle_closed_event", state) def handle_pull_request_target_synchronize(self, state: dict) -> bool: - return self._module.handle_pull_request_target_synchronize(state) + return self.handlers.call("handle_pull_request_target_synchronize", state) def handle_pull_request_review_event(self, state: dict) -> bool: - return self._module.handle_pull_request_review_event(state) + return self.handlers.call("handle_pull_request_review_event", state) def handle_comment_event(self, state: dict) -> bool: - return self._module.handle_comment_event(state) + return self.handlers.call("handle_comment_event", state) def handle_manual_dispatch(self, state: dict) -> bool: - return self._module.handle_manual_dispatch(state) + return self.handlers.call("handle_manual_dispatch", state) def handle_scheduled_check(self, state: dict) -> bool: - return self._module.handle_scheduled_check(state) + return self.handlers.call("handle_scheduled_check", state) def handle_workflow_run_event(self, state: dict) -> bool: - return self._module.handle_workflow_run_event(state) + return self.handlers.call("handle_workflow_run_event", state) def github_api(self, method: str, endpoint: str, data=None): - if self._github is not None: - return self._github.github_api(method, endpoint, data=data) - return self._module.github_api(method, endpoint, data=data) + return self.github.github_api(method, endpoint, data=data) def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): - if self._github is not None: - return self._github.github_api_request( - method, - endpoint, - data=data, - extra_headers=extra_headers, - **kwargs, - ) - return self._module.github_api_request( + return self.github.github_api_request( method, endpoint, data=data, @@ -153,6 +443,24 @@ def drain_touched_items(self) -> list[int]: self._touched_items.clear() return items + def stub_load_state(self, func: Callable[..., dict]) -> None: + self.state_store.stub_load(func) + + def stub_save_state(self, func: Callable[[dict], bool]) -> None: + self.state_store.stub_save(func) + + def stub_lock(self, *, acquire=None, release=None, refresh=None) -> None: + self.locks.stub(acquire=acquire, release=release, refresh=refresh) + + def stub_github(self, github) -> None: + self.github.stub(github) + + def stub_handler(self, name: str, func: Callable[[dict], bool]) -> None: + self.handlers.stub(name, func) + + def stub_deferred_payload(self, payload: dict) -> None: + self.deferred_payloads.set_payload(payload) + def stub_state_sequence(self, *states: dict) -> None: state_queue = [deepcopy(state) for state in states] @@ -163,36 +471,27 @@ def fake_load_state(*, fail_on_unavailable: bool = False): return state_queue[0] return state_queue.pop(0) - self._load_state_impl = fake_load_state + self.stub_load_state(fake_load_state) def stub_state_unavailable(self, message: str = "state unavailable") -> None: def fake_load_state(*, fail_on_unavailable: bool = False): assert fail_on_unavailable is True raise RuntimeError(message) - self._load_state_impl = fake_load_state + self.stub_load_state(fake_load_state) - def record_saves(self, snapshots: list): + def record_saves(self, snapshots: list) -> None: def fake_save_state(state: dict) -> bool: snapshots.append(json.loads(json.dumps(state))) return True - self._save_state_impl = fake_save_state - - def set_save_state(self, func) -> None: - self._save_state_impl = func - - def set_acquire_lock(self, func) -> None: - self._acquire_impl = func - - def set_release_lock(self, func) -> None: - self._release_impl = func + self.stub_save_state(fake_save_state) - def set_pass_until(self, func) -> None: - self._process_pass_until_impl = func + def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self._process_pass_until = func - def set_sync_members(self, func) -> None: - self._sync_members_impl = func + def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self._sync_members = func - def set_sync_status_labels(self, func) -> None: - self._sync_status_labels_impl = func + def stub_sync_status_labels(self, func: Callable[[dict, Any], bool]) -> None: + self._sync_status_labels = func diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index 535ef9f75..745e13570 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -4,34 +4,12 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import pull_request_payload, review_payload from .reviewer_bot_fakes import RouteGitHubApi, github_result -class _ConfigBag: - def __init__(self, monkeypatch): - self.monkeypatch = monkeypatch - self.values: dict[str, str] = {} - - def get(self, name: str, default: str = "") -> str: - return self.values.get(name, default) - - def set(self, name: str, value) -> None: - rendered = str(value) - self.values[name] = rendered - self.monkeypatch.setenv(name, rendered) - - -class _DeferredPayloads: - def __init__(self, payload: dict): - self.payload = payload - - def load(self) -> dict: - return self.payload - - def review_submitted_payload( *, pr_number: int, @@ -143,21 +121,16 @@ class ReconcileHarness: payload: dict def __post_init__(self) -> None: - self.config = _ConfigBag(self.monkeypatch) - self.deferred_payloads = _DeferredPayloads(self.payload) self.github = RouteGitHubApi() - runtime = ReviewerBotRuntime( - reviewer_bot, - config=self.config, - deferred_payloads=self.deferred_payloads, - github=self.github, - ) - self.monkeypatch.setattr(reviewer_bot, "RUNTIME", runtime) + self.runtime = FakeReviewerBotRuntime(self.monkeypatch, github=self.github) + self.config = self.runtime.config + self.runtime.stub_deferred_payload(self.payload) + self.monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) self.set_trigger_from_payload(self.payload) def set_payload(self, payload: dict) -> dict: self.payload = payload - self.deferred_payloads.payload = payload + self.runtime.stub_deferred_payload(payload) self.set_trigger_from_payload(payload) return payload @@ -290,13 +263,9 @@ def add_request_failure( ) def stub_head_repair(self, *, changed: bool = False, outcome: str = "unchanged") -> None: - self.monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=changed, - outcome=outcome, - ), + self.runtime.maybe_record_head_observation_repair = lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + changed=changed, + outcome=outcome, ) def stub_review_rebuild(self, *, changed: bool = False) -> None: diff --git a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py index f80fb3e57..23f7dada7 100644 --- a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -38,13 +38,12 @@ def fake_load_state(*, fail_on_unavailable=False): return initial_state return reloaded_state - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = fake_load_state - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - harness.runtime.set_save_state(lambda state: save_calls.append("42" in state["active_reviews"]) or True) - harness.runtime.set_sync_status_labels(lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(fake_load_state) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_save_state(lambda state: save_calls.append("42" in state["active_reviews"]) or True) + harness.stub_sync_status_labels(lambda state, issue_numbers: sync_calls.append((state, list(issue_numbers))) or True) result = harness.run_execute() @@ -74,13 +73,12 @@ def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch): save_called = {"value": False} sync_calls = [] - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: state - harness.runtime.set_pass_until(lambda current: (current, [])) - harness.runtime.set_sync_members(lambda current: (current, [])) - harness.runtime.set_save_state(lambda current: save_called.__setitem__("value", True) or True) - harness.runtime.set_sync_status_labels(lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) + harness.stub_save_state(lambda current: save_called.__setitem__("value", True) or True) + harness.stub_sync_status_labels(lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False) result = harness.run_execute() diff --git a/tests/integration/reviewer_bot/test_app_execution.py b/tests/integration/reviewer_bot/test_app_execution.py index 418525b57..067d87ead 100644 --- a/tests/integration/reviewer_bot/test_app_execution.py +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -39,14 +39,13 @@ def fake_sync_status_labels_for_items(state, issue_numbers): assert list(issue_numbers) == [42] return True - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = fake_load_state - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_comment_event", fake_handle_comment_event) - harness.runtime.set_save_state(fake_save_state) - harness.runtime.set_sync_status_labels(fake_sync_status_labels_for_items) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(fake_load_state) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_comment_event", fake_handle_comment_event) + harness.stub_save_state(fake_save_state) + harness.stub_sync_status_labels(fake_sync_status_labels_for_items) result = harness.run_execute() @@ -65,13 +64,12 @@ def fake_sync_status_labels_for_items(state, issue_numbers): def test_execute_run_returns_failure_when_save_state_fails(monkeypatch): harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_comment_event", lambda state: True) - harness.runtime.set_save_state(lambda state: False) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_comment_event", lambda state: True) + harness.stub_save_state(lambda state: False) result = harness.run_execute() @@ -86,12 +84,11 @@ def test_execute_run_returns_failure_for_invalid_workflow_run_context(monkeypatc WORKFLOW_RUN_EVENT="pull_request_review", WORKFLOW_RUN_EVENT_ACTION="submitted", ) - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_workflow_run_event", lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context"))) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_workflow_run_event", lambda state: (_ for _ in ()).throw(RuntimeError("invalid deferred context"))) result = harness.run_execute() diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py index 11712da5a..08042c67c 100644 --- a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -16,12 +16,12 @@ def test_execute_run_preview_reviewer_board_disabled_is_clean_noop(monkeypatch, REVIEWER_BOARD_ENABLED="false", ) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) - harness.runtime.set_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) - harness.runtime.set_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) - harness.runtime.set_save_state(lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state"))) - harness.runtime.set_sync_status_labels(lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_lock(acquire=lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.stub_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.stub_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) + harness.stub_save_state(lambda state: (_ for _ in ()).throw(AssertionError("preview should not save state"))) + harness.stub_sync_status_labels(lambda state, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) result = harness.run_execute() @@ -37,12 +37,12 @@ def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypa MANUAL_ACTION="preview-reviewer-board", REVIEWER_BOARD_ENABLED="true", ) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + monkeypatch.setattr(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) - harness.runtime.set_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) - harness.runtime.set_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_lock(acquire=lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.stub_pass_until(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.stub_sync_members(lambda state: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) result = harness.run_execute() @@ -58,12 +58,12 @@ def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monke REVIEWER_BOARD_ENABLED="true", REVIEWER_BOARD_TOKEN="board-token", ) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + monkeypatch.setattr(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_lock(acquire=lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) monkeypatch.setattr( - reviewer_bot, + harness.runtime, "github_graphql", lambda query, variables=None, *, token=None: { "data": { @@ -93,7 +93,7 @@ def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): REVIEWER_BOARD_TOKEN="board-token", ISSUE_NUMBER=42, ) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + monkeypatch.setattr(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) state = make_state() state["status_projection_epoch"] = "status_projection_v1" @@ -103,18 +103,14 @@ def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): review["assigned_at"] = "2026-03-20T12:34:56Z" review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: state - harness.runtime.set_acquire_lock(lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) - harness.runtime.set_pass_until(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) - harness.runtime.set_sync_members(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) - harness.runtime.set_save_state(lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state"))) - harness.runtime.set_sync_status_labels(lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) - monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []}, - ) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_lock(acquire=lambda: (_ for _ in ()).throw(AssertionError("preview should not acquire lock"))) + harness.stub_pass_until(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip pass-until processing"))) + harness.stub_sync_members(lambda current: (_ for _ in ()).throw(AssertionError("preview should skip member sync"))) + harness.stub_save_state(lambda current: (_ for _ in ()).throw(AssertionError("preview should not save state"))) + harness.stub_sync_status_labels(lambda current, issue_numbers: (_ for _ in ()).throw(AssertionError("preview should not sync labels"))) + monkeypatch.setattr(harness.runtime, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": None, "labels": []} result = harness.run_execute() diff --git a/tests/integration/reviewer_bot/test_app_state_unavailable.py b/tests/integration/reviewer_bot/test_app_state_unavailable.py index 0def27135..3bf060217 100644 --- a/tests/integration/reviewer_bot/test_app_state_unavailable.py +++ b/tests/integration/reviewer_bot/test_app_state_unavailable.py @@ -8,8 +8,7 @@ def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypatch): harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) + harness.stub_lock(acquire=lambda: None, release=lambda: True) harness.runtime.stub_state_unavailable("state unavailable") result = harness.run_execute() @@ -20,8 +19,7 @@ def test_execute_run_mutating_event_fails_closed_when_state_unavailable(monkeypa def test_execute_run_mutating_event_does_not_sync_or_save_when_state_unavailable(monkeypatch): harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") - harness.runtime.set_acquire_lock(lambda: None) - harness.runtime.set_release_lock(lambda: True) + harness.stub_lock(acquire=lambda: None, release=lambda: True) called = { "pass_until": False, @@ -47,10 +45,10 @@ def track_save(state): return True harness.runtime.stub_state_unavailable("state unavailable") - harness.runtime.set_pass_until(track_pass_until) - harness.runtime.set_sync_members(track_sync) - setattr(harness.runtime, "handle_comment_event", track_handler) - harness.runtime.set_save_state(track_save) + harness.stub_pass_until(track_pass_until) + harness.stub_sync_members(track_sync) + harness.stub_handler("handle_comment_event", track_handler) + harness.stub_save_state(track_save) result = harness.run_execute() diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py index 749b8a6b3..a153780ee 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -17,9 +17,9 @@ def fail_if_called(): acquire_called["value"] = True raise AssertionError("acquire_state_issue_lease_lock should not be called") - harness.runtime.set_acquire_lock(fail_if_called) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - setattr(harness.runtime, "handle_pull_request_review_event", lambda state: False) + harness.stub_lock(acquire=fail_if_called) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_handler("handle_pull_request_review_event", lambda state: False) harness.run_execute() @@ -35,11 +35,11 @@ def fail_if_called(): acquire_called["value"] = True raise AssertionError("acquire_state_issue_lease_lock should not be called") - harness.runtime.set_acquire_lock(fail_if_called) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_pull_request_review_event", lambda state: False) + harness.stub_lock(acquire=fail_if_called) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_pull_request_review_event", lambda state: False) harness.run_execute() @@ -63,12 +63,11 @@ def fake_acquire(): lock_expires_at="2999-01-01T00:00:00+00:00", ) - harness.runtime.set_acquire_lock(fake_acquire) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_workflow_run_event", lambda state: False) + harness.stub_lock(acquire=fake_acquire, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_workflow_run_event", lambda state: False) harness.run_execute() @@ -92,12 +91,11 @@ def fake_acquire(): lock_expires_at="2999-01-01T00:00:00+00:00", ) - harness.runtime.set_acquire_lock(fake_acquire) - harness.runtime.set_release_lock(lambda: True) - harness.runtime._load_state_impl = lambda *, fail_on_unavailable=False: make_state() - harness.runtime.set_pass_until(lambda state: (state, [])) - harness.runtime.set_sync_members(lambda state: (state, [])) - setattr(harness.runtime, "handle_workflow_run_event", lambda state: False) + harness.stub_lock(acquire=fake_acquire, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + harness.stub_pass_until(lambda state: (state, [])) + harness.stub_sync_members(lambda state: (state, [])) + harness.stub_handler("handle_workflow_run_event", lambda state: False) harness.run_execute() diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 1394802cc..95846e2ba 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -194,7 +194,7 @@ def test_handle_workflow_run_event_rebuilds_completion_from_live_review_commit_i ) ], ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True assert review["current_cycle_completion"]["completed"] is False @@ -260,7 +260,7 @@ def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_cur ), ], ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True accepted = review["reviewer_review"]["accepted"] @@ -299,7 +299,7 @@ def test_deferred_review_comment_reconcile_records_contributor_freshness(monkeyp author_type="User", author_association="CONTRIBUTOR", ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] @@ -338,7 +338,7 @@ def test_deferred_review_comment_reconcile_records_reviewer_freshness(monkeypatc author_type="User", author_association="MEMBER", ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:302" @@ -373,7 +373,7 @@ def test_deferred_review_comment_missing_live_object_preserves_source_time_fresh payload={"message": "missing"}, failure_kind="not_found", ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True assert review["reviewer_comment"]["accepted"]["semantic_key"] == "pull_request_review_comment:303" @@ -459,7 +459,7 @@ def test_deferred_comment_reconcile_hydrates_pr_author_context_for_contributor_f author_type="User", author_association="CONTRIBUTOR", ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" assert harness.run(state) is True assert state["active_reviews"]["42"]["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:199" @@ -499,7 +499,7 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke author_type="User", author_association="MEMBER", ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" claim_contexts = [] monkeypatch.setattr( reviewer_bot.reconcile_module, @@ -513,7 +513,7 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke } ) or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) or True, ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + harness.runtime.add_reaction = lambda *args, **kwargs: True assert harness.run(state) is True assert claim_contexts == [ diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index f7bfc48c8..89130af35 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -21,14 +21,14 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat issue_author="dana", is_pull_request=False, ) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] + harness.runtime.add_label = lambda issue_number, label: True monkeypatch.setattr( reviewer_bot, "sync_status_labels_for_items", lambda *args, **kwargs: pytest.fail("status sync should run only from app orchestration after save"), ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) + harness.runtime.add_reaction = lambda *args, **kwargs: True posted = harness.record_comments() assert reviewer_bot.handle_comment_event(state) is True @@ -54,36 +54,29 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) repository="rustfoundation/safety-critical-rust-coding-guidelines", ref="refs/heads/main", ) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, - ) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda: ["sign-off: create pr"]) - monkeypatch.setattr(reviewer_bot, "add_label", lambda issue_number, label: True) + harness.runtime.github_api = lambda method, endpoint, data=None: { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "dana"}, + } + harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] + harness.runtime.add_label = lambda issue_number, label: True monkeypatch.setattr( reviewer_bot, "sync_status_labels_for_items", lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command"), ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) + harness.runtime.add_reaction = lambda *args, **kwargs: True + harness.runtime.post_comment = lambda *args, **kwargs: True assert reviewer_bot.handle_comment_event(state) is False assert review["review_completion_source"] is None def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) called = {"post": 0} - monkeypatch.setattr(reviewer_bot, "find_open_pr_for_branch_status", lambda branch: ("unavailable", None)) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None, - ) + harness.runtime.find_open_pr_for_branch_status = lambda branch: ("unavailable", None) + harness.runtime.github_api = lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): reviewer_bot.create_pull_request("feature-branch", "main", 42) @@ -92,9 +85,10 @@ def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkey def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") @@ -110,7 +104,7 @@ def test_assign_command_posts_pr_guidance_on_success(monkeypatch): harness.stub_assignees([]) harness.stub_assignment() posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") @@ -127,7 +121,7 @@ def test_claim_command_posts_pr_guidance_on_success(monkeypatch): harness.stub_assignees([]) harness.stub_assignment() posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True response, success = reviewer_bot.handle_claim_command(state, 42, "felix91gr") @@ -149,9 +143,9 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) harness.stub_assignees(["alice"]) harness.stub_assignment() - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda issue_number, username: True) + harness.runtime.unassign_reviewer = lambda issue_number, username: True posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) @@ -168,7 +162,7 @@ def test_assign_from_queue_posts_guidance_only_once(monkeypatch): harness.stub_assignees([]) harness.stub_assignment() posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) @@ -189,10 +183,11 @@ def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unava def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) @@ -201,12 +196,13 @@ def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_pass_until_command( state, @@ -221,9 +217,10 @@ def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_claim_command(state, 42, "alice") @@ -232,8 +229,9 @@ def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" response, success = reviewer_bot.handle_release_command(state, 42, "alice", ["@bob"]) @@ -242,8 +240,9 @@ def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_release_command(state, 42, "alice") @@ -252,9 +251,10 @@ def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}] - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + harness.runtime.get_issue_assignees = lambda issue_number: None response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) @@ -321,12 +321,8 @@ def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privilege } } harness.set_manual_dispatch(source_event_key="issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]} + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" assert reviewer_bot.handle_manual_dispatch(state) is True pending = review["pending_privileged_commands"]["issue_comment:100"] From 1be3ae85e16441ca67e08bd996a241ea689c44ad Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 04:44:08 +0900 Subject: [PATCH 083/209] test(reviewer-bot): centralize fixture env and recorder helpers --- tests/conftest.py | 86 +++---------------- .../test_support_layer_ownership.py | 57 ++++++++++++ tests/fixtures/app_harness.py | 4 +- tests/fixtures/commands_harness.py | 44 +++++----- tests/fixtures/comment_routing_harness.py | 49 +++++------ tests/fixtures/reconcile_harness.py | 12 ++- tests/fixtures/reviewer_bot_env.py | 55 ++++++++++++ tests/fixtures/reviewer_bot_recorders.py | 42 +++++++++ .../test_comment_routing_integration.py | 20 ++--- tests/unit/reviewer_bot/test_commands.py | 2 +- .../unit/reviewer_bot/test_comment_routing.py | 2 +- 11 files changed, 232 insertions(+), 141 deletions(-) create mode 100644 tests/contract/reviewer_bot/test_support_layer_ownership.py create mode 100644 tests/fixtures/reviewer_bot_env.py create mode 100644 tests/fixtures/reviewer_bot_recorders.py diff --git a/tests/conftest.py b/tests/conftest.py index 2757cc93d..d8010d578 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,54 +1,20 @@ import pytest from scripts import reviewer_bot +from tests.fixtures.reviewer_bot_env import ( + build_test_lease_context, + reset_reviewer_bot_process_state, +) +from tests.fixtures.reviewer_bot_recorders import ( + record_comment_dicts, + record_status_label_ops, +) @pytest.fixture(autouse=True) -def clear_reviewer_bot_env(): - env_vars = { - "COMMENT_BODY", - "COMMENT_AUTHOR", - "COMMENT_ID", - "COMMENT_SOURCE_EVENT_KEY", - "ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", - "EVENT_ACTION", - "EVENT_NAME", - "ISSUE_NUMBER", - "ISSUE_AUTHOR", - "IS_PULL_REQUEST", - "ISSUE_LABELS", - "LABEL_NAME", - "MANUAL_ACTION", - "PR_IS_CROSS_REPOSITORY", - "REVIEW_AUTHOR", - "REVIEW_STATE", - "REPO_OWNER", - "REPO_NAME", - "WORKFLOW_RUN_EVENT", - "WORKFLOW_RUN_EVENT_ACTION", - "WORKFLOW_RUN_HEAD_SHA", - "WORKFLOW_RUN_RECONCILE_PR_NUMBER", - "WORKFLOW_RUN_RECONCILE_HEAD_SHA", - "WORKFLOW_RUN_ID", - "WORKFLOW_NAME", - "WORKFLOW_JOB_NAME", - } +def reset_reviewer_bot_process_state_fixture(): with pytest.MonkeyPatch().context() as monkeypatch: - for name in env_vars: - monkeypatch.delenv(name, raising=False) - monkeypatch.setattr( - reviewer_bot, - "ACTIVE_LEASE_CONTEXT", - reviewer_bot.LeaseContext( - lock_token="test-lock-token", - lock_owner_run_id="test-run", - lock_owner_workflow="test-workflow", - lock_owner_job="test-job", - state_issue_url="https://example.com/state", - ), - ) - monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + reset_reviewer_bot_process_state(monkeypatch) yield @@ -63,13 +29,7 @@ def setter(values: dict[str, str]): @pytest.fixture def lease_context(): - return reviewer_bot.LeaseContext( - lock_token="test-lock-token", - lock_owner_run_id="test-run", - lock_owner_workflow="test-workflow", - lock_owner_job="test-job", - state_issue_url="https://example.com/state", - ) + return build_test_lease_context() @pytest.fixture @@ -113,29 +73,9 @@ def stub_api(monkeypatch): @pytest.fixture def captured_comments(monkeypatch): - comments = [] - - def record_comment(issue_number, body): - comments.append({"issue_number": issue_number, "body": body}) - return True - - monkeypatch.setattr(reviewer_bot, "post_comment", record_comment) - return comments + return record_comment_dicts(reviewer_bot) @pytest.fixture def captured_status_label_ops(monkeypatch): - operations = [] - - def record_add(issue_number, label): - operations.append(("add", issue_number, label)) - return True - - def record_remove(issue_number, label): - operations.append(("remove", issue_number, label)) - return True - - monkeypatch.setattr(reviewer_bot, "add_label_with_status", record_add) - monkeypatch.setattr(reviewer_bot, "remove_label_with_status", record_remove) - monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) - return operations + return record_status_label_ops(reviewer_bot) diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py new file mode 100644 index 000000000..0838ee72d --- /dev/null +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from tests.fixtures import reviewer_bot_env, reviewer_bot_recorders + +pytestmark = pytest.mark.contract + + +ROOT = Path(__file__).resolve().parents[3] + + +def _read(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def test_support_layer_has_owned_env_and_recorder_modules(): + assert reviewer_bot_env is not None + assert reviewer_bot_recorders is not None + + +def test_harnesses_do_not_define_local_config_bags_or_deferred_payload_loaders(): + for relative_path in [ + "tests/fixtures/app_harness.py", + "tests/fixtures/commands_harness.py", + "tests/fixtures/comment_routing_harness.py", + "tests/fixtures/reconcile_harness.py", + ]: + text = _read(relative_path) + assert "class _ConfigBag" not in text + assert "class _DeferredPayloads" not in text + + +def test_harnesses_use_owned_env_and_recorder_homes(): + app_harness = _read("tests/fixtures/app_harness.py") + commands_harness = _read("tests/fixtures/commands_harness.py") + comment_harness = _read("tests/fixtures/comment_routing_harness.py") + conftest = _read("tests/conftest.py") + + assert "from .reviewer_bot_env import" in app_harness or "reviewer_bot_env" in app_harness + assert "from .reviewer_bot_env import" in commands_harness or "reviewer_bot_env" in commands_harness + assert "from .reviewer_bot_env import" in comment_harness or "reviewer_bot_env" in comment_harness + assert "reviewer_bot_env" in conftest + assert "reviewer_bot_recorders" in commands_harness + assert "reviewer_bot_recorders" in comment_harness + assert "reviewer_bot_recorders" in conftest + + +def test_no_second_fake_runtime_or_transport_home_exists(): + fixtures_dir = ROOT / "tests/fixtures" + fixture_files = {path.name for path in fixtures_dir.glob("*.py")} + + assert "fake_runtime.py" in fixture_files + assert "reviewer_bot_fakes.py" in fixture_files + assert "commands_harness.py" in fixture_files + assert "comment_routing_harness.py" in fixture_files + assert "reconcile_harness.py" in fixture_files diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index d3d6affc0..fe6ccc4b2 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -5,6 +5,7 @@ from scripts import reviewer_bot from .fake_runtime import FakeReviewerBotRuntime +from .reviewer_bot_env import set_env_values @dataclass @@ -22,8 +23,7 @@ def __init__(self, monkeypatch): self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_event(self, **values) -> None: - for name, value in values.items(): - self.config.set(name, value) + set_env_values(self.config, **values) def set_state_sequence(self, *states: dict) -> None: self.runtime.stub_state_sequence(*states) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index 4887d177b..ad05bb625 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -5,6 +5,8 @@ from scripts import reviewer_bot from .fake_runtime import FakeReviewerBotRuntime +from .reviewer_bot_env import set_env_values +from .reviewer_bot_recorders import record_comments class AutomationRunner: @@ -46,26 +48,28 @@ def set_comment_command( comment_id: int = 100, created_at: str = "2026-03-17T10:00:00Z", ) -> None: - self.config.set("ISSUE_NUMBER", issue_number) - self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) - self.config.set("ISSUE_AUTHOR", issue_author) - self.config.set("COMMENT_USER_TYPE", "User") - self.config.set("COMMENT_AUTHOR", actor) - self.config.set("COMMENT_ID", comment_id) - self.config.set("COMMENT_CREATED_AT", created_at) - self.config.set("COMMENT_BODY", body) + values = { + "ISSUE_NUMBER": issue_number, + "IS_PULL_REQUEST": str(is_pull_request).lower(), + "ISSUE_AUTHOR": issue_author, + "COMMENT_USER_TYPE": "User", + "COMMENT_AUTHOR": actor, + "COMMENT_ID": comment_id, + "COMMENT_CREATED_AT": created_at, + "COMMENT_BODY": body, + } if author_association: - self.config.set("COMMENT_AUTHOR_ASSOCIATION", author_association) + values["COMMENT_AUTHOR_ASSOCIATION"] = author_association if workflow_file: - self.config.set("CURRENT_WORKFLOW_FILE", workflow_file) + values["CURRENT_WORKFLOW_FILE"] = workflow_file if repository: - self.config.set("GITHUB_REPOSITORY", repository) + values["GITHUB_REPOSITORY"] = repository if ref: - self.config.set("GITHUB_REF", ref) + values["GITHUB_REF"] = ref + set_env_values(self.config, **values) def set_assignment_context(self, *, issue_author: str, is_pull_request: bool) -> None: - self.config.set("ISSUE_AUTHOR", issue_author) - self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) + set_env_values(self.config, ISSUE_AUTHOR=issue_author, IS_PULL_REQUEST=str(is_pull_request).lower()) def set_privileged_context( self, @@ -76,19 +80,15 @@ def set_privileged_context( ) -> None: import json - self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) - self.config.set("ISSUE_LABELS", json.dumps(labels)) + set_env_values(self.config, IS_PULL_REQUEST=str(is_pull_request).lower(), ISSUE_LABELS=json.dumps(labels)) if target_repo_root is not None: self.config.set("REVIEWER_BOT_TARGET_REPO_ROOT", target_repo_root) def set_manual_dispatch(self, *, source_event_key: str) -> None: - self.config.set("MANUAL_ACTION", "execute-pending-privileged-command") - self.config.set("PRIVILEGED_SOURCE_EVENT_KEY", source_event_key) + set_env_values(self.config, MANUAL_ACTION="execute-pending-privileged-command", PRIVILEGED_SOURCE_EVENT_KEY=source_event_key) - def record_comments(self): - posted = [] - self.runtime.post_comment = lambda issue_number, body: posted.append((issue_number, body)) or True - return posted + def capture_posted_comments(self): + return record_comments(self.runtime) def stub_assignees(self, assignees): self.runtime.get_issue_assignees = lambda issue_number: assignees diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 8b9a07689..7e61bf104 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -1,18 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass - from scripts import reviewer_bot from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext from .fake_runtime import FakeReviewerBotRuntime +from .reviewer_bot_env import set_env_values from .reviewer_bot_fakes import RouteGitHubApi - - -@dataclass -class SideEffects: - comments: list[tuple[int, str]] - reactions: list[tuple[int, str]] +from .reviewer_bot_recorders import record_comment_side_effects class CommentRoutingHarness: @@ -87,14 +81,10 @@ def add_pull_request_metadata( ) self.github.add_request("GET", f"pulls/{issue_number}", status_code=200, payload=payload) - def side_effects(self) -> SideEffects: - comments: list[tuple[int, str]] = [] - reactions: list[tuple[int, str]] = [] - self.runtime.post_comment = lambda issue_number, body: comments.append((issue_number, body)) or True - self.runtime.add_reaction = lambda comment_id, reaction: reactions.append((comment_id, reaction)) or True - return SideEffects(comments=comments, reactions=reactions) + def capture_comment_side_effects(self): + return record_comment_side_effects(self.runtime) - def set_wrapper_env( + def apply_wrapper_inputs( self, *, issue_number: int, @@ -111,20 +101,23 @@ def set_wrapper_env( github_repository: str = "", github_ref: str = "", ) -> None: - self.config.set("ISSUE_NUMBER", issue_number) - self.config.set("IS_PULL_REQUEST", str(is_pull_request).lower()) - self.config.set("ISSUE_STATE", issue_state) - self.config.set("ISSUE_AUTHOR", issue_author) - self.config.set("COMMENT_ID", comment_id) - self.config.set("COMMENT_AUTHOR", comment_author) - self.config.set("COMMENT_BODY", comment_body) - self.config.set("COMMENT_CREATED_AT", comment_created_at) - self.config.set("COMMENT_USER_TYPE", comment_user_type) + values = { + "ISSUE_NUMBER": issue_number, + "IS_PULL_REQUEST": str(is_pull_request).lower(), + "ISSUE_STATE": issue_state, + "ISSUE_AUTHOR": issue_author, + "COMMENT_ID": comment_id, + "COMMENT_AUTHOR": comment_author, + "COMMENT_BODY": comment_body, + "COMMENT_CREATED_AT": comment_created_at, + "COMMENT_USER_TYPE": comment_user_type, + } if comment_author_association: - self.config.set("COMMENT_AUTHOR_ASSOCIATION", comment_author_association) + values["COMMENT_AUTHOR_ASSOCIATION"] = comment_author_association if current_workflow_file: - self.config.set("CURRENT_WORKFLOW_FILE", current_workflow_file) + values["CURRENT_WORKFLOW_FILE"] = current_workflow_file if github_repository: - self.config.set("GITHUB_REPOSITORY", github_repository) + values["GITHUB_REPOSITORY"] = github_repository if github_ref: - self.config.set("GITHUB_REF", github_ref) + values["GITHUB_REF"] = github_ref + set_env_values(self.config, **values) diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index 745e13570..5a1335b04 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -7,6 +7,7 @@ from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import pull_request_payload, review_payload +from .reviewer_bot_env import set_env_values from .reviewer_bot_fakes import RouteGitHubApi, github_result @@ -135,10 +136,13 @@ def set_payload(self, payload: dict) -> dict: return payload def set_trigger_from_payload(self, payload: dict, *, conclusion: str = "success") -> None: - self.config.set("WORKFLOW_RUN_TRIGGERING_NAME", payload["source_workflow_name"]) - self.config.set("WORKFLOW_RUN_TRIGGERING_ID", payload["source_run_id"]) - self.config.set("WORKFLOW_RUN_TRIGGERING_ATTEMPT", payload["source_run_attempt"]) - self.config.set("WORKFLOW_RUN_TRIGGERING_CONCLUSION", conclusion) + set_env_values( + self.config, + WORKFLOW_RUN_TRIGGERING_NAME=payload["source_workflow_name"], + WORKFLOW_RUN_TRIGGERING_ID=payload["source_run_id"], + WORKFLOW_RUN_TRIGGERING_ATTEMPT=payload["source_run_attempt"], + WORKFLOW_RUN_TRIGGERING_CONCLUSION=conclusion, + ) def add_pull_request( self, diff --git a/tests/fixtures/reviewer_bot_env.py b/tests/fixtures/reviewer_bot_env.py new file mode 100644 index 000000000..6103dc8aa --- /dev/null +++ b/tests/fixtures/reviewer_bot_env.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from scripts import reviewer_bot + +CLEAR_REVIEWER_BOT_ENV_VARS = { + "ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", + "COMMENT_AUTHOR", + "COMMENT_BODY", + "COMMENT_ID", + "COMMENT_SOURCE_EVENT_KEY", + "EVENT_ACTION", + "EVENT_NAME", + "IS_PULL_REQUEST", + "ISSUE_AUTHOR", + "ISSUE_LABELS", + "ISSUE_NUMBER", + "LABEL_NAME", + "MANUAL_ACTION", + "PR_IS_CROSS_REPOSITORY", + "REPO_NAME", + "REPO_OWNER", + "REVIEW_AUTHOR", + "REVIEW_STATE", + "WORKFLOW_JOB_NAME", + "WORKFLOW_NAME", + "WORKFLOW_RUN_EVENT", + "WORKFLOW_RUN_EVENT_ACTION", + "WORKFLOW_RUN_HEAD_SHA", + "WORKFLOW_RUN_ID", + "WORKFLOW_RUN_RECONCILE_HEAD_SHA", + "WORKFLOW_RUN_RECONCILE_PR_NUMBER", +} + + +def build_test_lease_context(): + return reviewer_bot.LeaseContext( + lock_token="test-lock-token", + lock_owner_run_id="test-run", + lock_owner_workflow="test-workflow", + lock_owner_job="test-job", + state_issue_url="https://example.com/state", + ) + + +def reset_reviewer_bot_process_state(monkeypatch) -> None: + for name in CLEAR_REVIEWER_BOT_ENV_VARS: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", build_test_lease_context()) + monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) + monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + + +def set_env_values(config, **values) -> None: + for name, value in values.items(): + config.set(name, value) diff --git a/tests/fixtures/reviewer_bot_recorders.py b/tests/fixtures/reviewer_bot_recorders.py new file mode 100644 index 000000000..9a4a187cc --- /dev/null +++ b/tests/fixtures/reviewer_bot_recorders.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CommentSideEffects: + comments: list[tuple[int, str]] + reactions: list[tuple[int, str]] + + +def record_comments(target): + comments: list[tuple[int, str]] = [] + target.post_comment = lambda issue_number, body: comments.append((issue_number, body)) or True + return comments + + +def record_comment_dicts(target): + comments = [] + target.post_comment = lambda issue_number, body: comments.append({"issue_number": issue_number, "body": body}) or True + return comments + + +def record_reactions(target): + reactions: list[tuple[int, str]] = [] + target.add_reaction = lambda comment_id, reaction: reactions.append((comment_id, reaction)) or True + return reactions + + +def record_comment_side_effects(target) -> CommentSideEffects: + return CommentSideEffects( + comments=record_comments(target), + reactions=record_reactions(target), + ) + + +def record_status_label_ops(target): + operations = [] + target.add_label_with_status = lambda issue_number, label: operations.append(("add", issue_number, label)) or True + target.remove_label_with_status = lambda issue_number, label: operations.append(("remove", issue_number, label)) or True + target.ensure_label_exists = lambda *args, **kwargs: True + return operations diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index 5b17ef05e..4907dc4b2 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -22,11 +22,11 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypa comment_author="dana", comment_body="@guidelines-bot /accept-no-fls-changes", ) - effects = harness.side_effects() - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "granted") + effects = harness.capture_comment_side_effects() + harness.runtime.parse_issue_labels = lambda: [reviewer_bot.FLS_AUDIT_LABEL] + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" - assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is True + assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is True pending = state["active_reviews"]["42"]["pending_privileged_commands"] assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" assert pending["issue_comment:100"]["authorization"]["authorized"] is True @@ -51,7 +51,7 @@ def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypat comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False + assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): @@ -65,11 +65,11 @@ def test_closed_non_pr_command_comment_does_not_create_pending_privileged_comman comment_author="dana", comment_body="@guidelines-bot /accept-no-fls-changes", ) - effects = harness.side_effects() + effects = harness.capture_comment_side_effects() monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False + assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} assert effects.comments == [] @@ -88,7 +88,7 @@ def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is True + assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is True assert "42" not in state["active_reviews"] def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): @@ -103,7 +103,7 @@ def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(reviewer_bot, state, request) is False + assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): @@ -112,7 +112,7 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - harness.set_wrapper_env( + harness.apply_wrapper_inputs( issue_number=42, is_pull_request=False, issue_state="open", diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index 89130af35..11f5899ff 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -29,7 +29,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat lambda *args, **kwargs: pytest.fail("status sync should run only from app orchestration after save"), ) harness.runtime.add_reaction = lambda *args, **kwargs: True - posted = harness.record_comments() + posted = harness.capture_posted_comments() assert reviewer_bot.handle_comment_event(state) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 85fe3966d..c7d641b45 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -129,7 +129,7 @@ def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypa def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monkeypatch): harness = CommentRoutingHarness(monkeypatch) - harness.set_wrapper_env( + harness.apply_wrapper_inputs( issue_number=42, is_pull_request=True, issue_author="dana", From 11b404a145607e0154a423b4ec790350d4345c88 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 04:55:09 +0900 Subject: [PATCH 084/209] test(reviewer-bot): initialize fake runtime lease context --- tests/fixtures/fake_runtime.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index fe77256a8..fecb3ad7b 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -28,6 +28,7 @@ STATUS_PROJECTION_EPOCH, AssignmentAttempt, ) +from scripts.reviewer_bot_lib.context import LeaseContext class ConfigBag: @@ -179,6 +180,13 @@ class FakeReviewerBotRuntime: timezone = timezone def __init__(self, monkeypatch, *, github=None): + self.ACTIVE_LEASE_CONTEXT = LeaseContext( + lock_token="test-lock-token", + lock_owner_run_id="test-run", + lock_owner_workflow="test-workflow", + lock_owner_job="test-job", + state_issue_url="https://example.com/state", + ) self.config = ConfigBag(monkeypatch) self.outputs = OutputCapture() self.deferred_payloads = DeferredPayloadStore() From 6b6367e0e6e5eea6c87afab72dcd6f3c065af3b2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 05:03:50 +0900 Subject: [PATCH 085/209] refactor(reviewer-bot): centralize env request builders --- scripts/reviewer_bot_lib/app.py | 64 +-------- scripts/reviewer_bot_lib/automation.py | 38 ++--- scripts/reviewer_bot_lib/commands.py | 34 +---- scripts/reviewer_bot_lib/comment_routing.py | 35 ++--- scripts/reviewer_bot_lib/event_inputs.py | 146 ++++++++++++++++++++ 5 files changed, 176 insertions(+), 141 deletions(-) create mode 100644 scripts/reviewer_bot_lib/event_inputs.py diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 1cbb7a098..53de269a2 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -1,6 +1,5 @@ """Top-level reviewer-bot orchestration.""" -import json import sys from .context import ( @@ -9,6 +8,7 @@ EventContext, ExecutionResult, ) +from .event_inputs import build_event_context as decode_event_context from .maintenance import ( collect_status_projection_repair_items, status_projection_repair_needed, @@ -52,68 +52,8 @@ def _mark_projection_repair_needed(bot: AppExecutionRuntime, state: dict, issue_ return changed -def _parse_optional_int(value: str) -> int | None: - value = value.strip() - if not value: - return None - try: - return int(value) - except ValueError: - return None - - -def _parse_optional_bool(value: str) -> bool | None: - value = value.strip().lower() - if not value: - return None - if value == "true": - return True - if value == "false": - return False - return None - - -def _parse_issue_labels(value: str) -> tuple[str, ...]: - value = value.strip() - if not value: - return () - try: - payload = json.loads(value) - except json.JSONDecodeError: - return () - if not isinstance(payload, list): - return () - return tuple(label for label in payload if isinstance(label, str)) - - def build_event_context(bot: AppEventContextRuntime) -> EventContext: - return EventContext( - event_name=bot.get_config_value("EVENT_NAME").strip(), - event_action=bot.get_config_value("EVENT_ACTION").strip(), - issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")), - is_pull_request=_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST")), - issue_author=bot.get_config_value("ISSUE_AUTHOR").strip() or None, - issue_state=bot.get_config_value("ISSUE_STATE").strip() or None, - issue_labels=_parse_issue_labels(bot.get_config_value("ISSUE_LABELS")), - comment_id=_parse_optional_int(bot.get_config_value("COMMENT_ID")), - comment_author=bot.get_config_value("COMMENT_AUTHOR").strip() or None, - comment_body=bot.get_config_value("COMMENT_BODY") or None, - comment_source_event_key=bot.get_config_value("COMMENT_SOURCE_EVENT_KEY").strip() or None, - pr_is_cross_repository=_parse_optional_bool(bot.get_config_value("PR_IS_CROSS_REPOSITORY")), - review_author=bot.get_config_value("REVIEW_AUTHOR").strip() or None, - review_state=bot.get_config_value("REVIEW_STATE").strip() or None, - workflow_run_event=bot.get_config_value("WORKFLOW_RUN_EVENT").strip() or None, - workflow_run_event_action=bot.get_config_value("WORKFLOW_RUN_EVENT_ACTION").strip() or None, - workflow_run_head_sha=bot.get_config_value("WORKFLOW_RUN_HEAD_SHA").strip() or None, - workflow_run_reconcile_pr_number=_parse_optional_int( - bot.get_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER") - ), - workflow_run_reconcile_head_sha=bot.get_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA").strip() or None, - workflow_run_id=_parse_optional_int(bot.get_config_value("WORKFLOW_RUN_ID")), - workflow_name=bot.get_config_value("WORKFLOW_NAME").strip() or None, - workflow_job_name=bot.get_config_value("WORKFLOW_JOB_NAME").strip() or None, - manual_action=bot.get_config_value("MANUAL_ACTION").strip() or None, - ) + return decode_event_context(bot) def _classify_event_intent_from_context(bot: AppEventContextRuntime, context: EventContext) -> str: diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index d7ff2b1bc..4d154e48b 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -1,12 +1,17 @@ """Automation-heavy reviewer-bot helpers.""" -import json -import os import subprocess from datetime import datetime, timezone from pathlib import Path from .context import PrivilegedCommandRequest +from .event_inputs import ( + build_privileged_command_request as decode_privileged_command_request, +) +from .event_inputs import ( + get_target_repo_root_from_env, + parse_issue_labels_env, +) def run_command(command: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: @@ -36,39 +41,22 @@ def list_changed_files(repo_root: Path) -> list[str]: def get_target_repo_root() -> Path: - configured = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() - if configured: - return Path(configured) + configured = get_target_repo_root_from_env() + if configured is not None: + return configured return Path(__file__).resolve().parents[2] def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: - target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() - workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() - return PrivilegedCommandRequest( + return decode_privileged_command_request( issue_number=issue_number, actor=actor, command_name=command_name, - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=tuple(bot_label for bot_label in bot_parse_issue_labels()), - target_repo_root=target_repo_root, - workflow_run_reconcile_pr_number=( - int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None - ), - workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), - workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), ) def bot_parse_issue_labels() -> list[str]: - labels_json = os.environ.get("ISSUE_LABELS", "[]") - try: - labels = json.loads(labels_json) - except Exception: - labels = [] - if not isinstance(labels, list): - return [] - return [str(label) for label in labels] + return parse_issue_labels_env() def get_default_branch(bot) -> str: @@ -79,7 +67,7 @@ def get_default_branch(bot) -> str: def find_open_pr_for_branch_status(bot, branch: str) -> tuple[str, dict | None]: - owner = os.environ.get("REPO_OWNER", "").strip() + owner = bot.get_config_value("REPO_OWNER", "").strip() branch = branch.strip() if not owner or not branch: return "not_found", None diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 325d47752..c87463f84 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -1,47 +1,27 @@ """Reviewer-bot command parsing and handlers.""" -import json -import os import re from datetime import datetime, timezone -from .automation import ( - bot_parse_issue_labels, -) -from .automation import ( - build_privileged_command_request as automation_build_privileged_command_request, -) +from .automation import bot_parse_issue_labels from .automation import ( find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status, ) from .config import AssignmentAttempt from .context import AssignmentRequest, PrivilegedCommandRequest +from .event_inputs import build_assignment_request as decode_assignment_request +from .event_inputs import ( + build_privileged_command_request as decode_privileged_command_request, +) from .guidance import get_issue_guidance, get_pr_guidance -def _parse_issue_labels(labels_json: str) -> list[str]: - try: - labels = json.loads(labels_json) - except json.JSONDecodeError: - labels = [] - if not isinstance(labels, list): - return [] - return [str(label) for label in labels] - - def build_assignment_request(*, issue_number: int) -> AssignmentRequest: - return AssignmentRequest( - issue_number=issue_number, - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=tuple(_parse_issue_labels(os.environ.get("ISSUE_LABELS", "[]"))), - repo_owner=os.environ.get("REPO_OWNER", ""), - repo_name=os.environ.get("REPO_NAME", ""), - ) + return decode_assignment_request(issue_number=issue_number) def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: - return automation_build_privileged_command_request( + return decode_privileged_command_request( issue_number=issue_number, actor=actor, command_name=command_name, diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index e0667876b..15513f813 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -3,11 +3,16 @@ from __future__ import annotations import hashlib -import os import re from datetime import datetime, timezone from .context import CommentEventRequest, PrCommentTrustContext +from .event_inputs import ( + build_comment_event_request as decode_comment_event_request, +) +from .event_inputs import ( + build_pr_comment_trust_context as decode_pr_comment_trust_context, +) def _now() -> datetime: @@ -23,35 +28,11 @@ def _runtime_epoch(state: dict) -> str: def build_comment_event_request(*, issue_number: int | None = None) -> CommentEventRequest: - return CommentEventRequest( - issue_number=issue_number if issue_number is not None else int(os.environ.get("ISSUE_NUMBER", "0") or 0), - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_state=os.environ.get("ISSUE_STATE", "").strip().lower(), - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), - comment_author=os.environ.get("COMMENT_AUTHOR", ""), - comment_author_id=int(os.environ.get("COMMENT_AUTHOR_ID", "0") or 0), - comment_body=os.environ.get("COMMENT_BODY", ""), - comment_created_at=os.environ.get("COMMENT_CREATED_AT", ""), - comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), - comment_user_type=os.environ.get("COMMENT_USER_TYPE", "").strip(), - comment_sender_type=os.environ.get("COMMENT_SENDER_TYPE", "").strip(), - comment_installation_id=os.environ.get("COMMENT_INSTALLATION_ID", "").strip(), - comment_performed_via_github_app=( - os.environ.get("COMMENT_PERFORMED_VIA_GITHUB_APP", "").strip().lower() == "true" - ), - ) + return decode_comment_event_request(issue_number=issue_number) def build_pr_comment_trust_context() -> PrCommentTrustContext: - return PrCommentTrustContext( - github_repository=os.environ.get("GITHUB_REPOSITORY", ""), - comment_author_association=os.environ.get("COMMENT_AUTHOR_ASSOCIATION", "").strip(), - current_workflow_file=os.environ.get("CURRENT_WORKFLOW_FILE", "").strip(), - github_ref=os.environ.get("GITHUB_REF", "").strip(), - github_run_id=int(os.environ.get("GITHUB_RUN_ID", "0") or 0), - github_run_attempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "0") or 0), - ) + return decode_pr_comment_trust_context() def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) -> bool: diff --git a/scripts/reviewer_bot_lib/event_inputs.py b/scripts/reviewer_bot_lib/event_inputs.py new file mode 100644 index 000000000..d1507fcc1 --- /dev/null +++ b/scripts/reviewer_bot_lib/event_inputs.py @@ -0,0 +1,146 @@ +"""Decode env/config inputs into typed reviewer-bot request objects.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from .context import ( + AppEventContextRuntime, + AssignmentRequest, + CommentEventRequest, + EventContext, + PrCommentTrustContext, + PrivilegedCommandRequest, +) + + +def _parse_optional_int(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + +def _parse_optional_bool(value: str) -> bool | None: + value = value.strip().lower() + if not value: + return None + if value == "true": + return True + if value == "false": + return False + return None + + +def _parse_labels(value: str) -> tuple[str, ...]: + value = value.strip() + if not value: + return () + try: + payload = json.loads(value) + except json.JSONDecodeError: + return () + if not isinstance(payload, list): + return () + return tuple(str(label) for label in payload) + + +def parse_issue_labels_env() -> list[str]: + return list(_parse_labels(os.environ.get("ISSUE_LABELS", "[]"))) + + +def get_target_repo_root_from_env() -> Path | None: + configured = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() + if not configured: + return None + return Path(configured) + + +def build_event_context(bot: AppEventContextRuntime) -> EventContext: + return EventContext( + event_name=bot.get_config_value("EVENT_NAME").strip(), + event_action=bot.get_config_value("EVENT_ACTION").strip(), + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")), + is_pull_request=_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST")), + issue_author=bot.get_config_value("ISSUE_AUTHOR").strip() or None, + issue_state=bot.get_config_value("ISSUE_STATE").strip() or None, + issue_labels=_parse_labels(bot.get_config_value("ISSUE_LABELS")), + comment_id=_parse_optional_int(bot.get_config_value("COMMENT_ID")), + comment_author=bot.get_config_value("COMMENT_AUTHOR").strip() or None, + comment_body=bot.get_config_value("COMMENT_BODY") or None, + comment_source_event_key=bot.get_config_value("COMMENT_SOURCE_EVENT_KEY").strip() or None, + pr_is_cross_repository=_parse_optional_bool(bot.get_config_value("PR_IS_CROSS_REPOSITORY")), + review_author=bot.get_config_value("REVIEW_AUTHOR").strip() or None, + review_state=bot.get_config_value("REVIEW_STATE").strip() or None, + workflow_run_event=bot.get_config_value("WORKFLOW_RUN_EVENT").strip() or None, + workflow_run_event_action=bot.get_config_value("WORKFLOW_RUN_EVENT_ACTION").strip() or None, + workflow_run_head_sha=bot.get_config_value("WORKFLOW_RUN_HEAD_SHA").strip() or None, + workflow_run_reconcile_pr_number=_parse_optional_int(bot.get_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER")), + workflow_run_reconcile_head_sha=bot.get_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA").strip() or None, + workflow_run_id=_parse_optional_int(bot.get_config_value("WORKFLOW_RUN_ID")), + workflow_name=bot.get_config_value("WORKFLOW_NAME").strip() or None, + workflow_job_name=bot.get_config_value("WORKFLOW_JOB_NAME").strip() or None, + manual_action=bot.get_config_value("MANUAL_ACTION").strip() or None, + ) + + +def build_comment_event_request(*, issue_number: int | None = None) -> CommentEventRequest: + return CommentEventRequest( + issue_number=issue_number if issue_number is not None else int(os.environ.get("ISSUE_NUMBER", "0") or 0), + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_state=os.environ.get("ISSUE_STATE", "").strip().lower(), + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), + comment_author=os.environ.get("COMMENT_AUTHOR", ""), + comment_author_id=int(os.environ.get("COMMENT_AUTHOR_ID", "0") or 0), + comment_body=os.environ.get("COMMENT_BODY", ""), + comment_created_at=os.environ.get("COMMENT_CREATED_AT", ""), + comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), + comment_user_type=os.environ.get("COMMENT_USER_TYPE", "").strip(), + comment_sender_type=os.environ.get("COMMENT_SENDER_TYPE", "").strip(), + comment_installation_id=os.environ.get("COMMENT_INSTALLATION_ID", "").strip(), + comment_performed_via_github_app=os.environ.get("COMMENT_PERFORMED_VIA_GITHUB_APP", "").strip().lower() == "true", + ) + + +def build_pr_comment_trust_context() -> PrCommentTrustContext: + return PrCommentTrustContext( + github_repository=os.environ.get("GITHUB_REPOSITORY", ""), + comment_author_association=os.environ.get("COMMENT_AUTHOR_ASSOCIATION", "").strip(), + current_workflow_file=os.environ.get("CURRENT_WORKFLOW_FILE", "").strip(), + github_ref=os.environ.get("GITHUB_REF", "").strip(), + github_run_id=int(os.environ.get("GITHUB_RUN_ID", "0") or 0), + github_run_attempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "0") or 0), + ) + + +def build_assignment_request(*, issue_number: int) -> AssignmentRequest: + return AssignmentRequest( + issue_number=issue_number, + issue_author=os.environ.get("ISSUE_AUTHOR", ""), + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=_parse_labels(os.environ.get("ISSUE_LABELS", "[]")), + repo_owner=os.environ.get("REPO_OWNER", ""), + repo_name=os.environ.get("REPO_NAME", ""), + ) + + +def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: + target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() + workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() + return PrivilegedCommandRequest( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=_parse_labels(os.environ.get("ISSUE_LABELS", "[]")), + target_repo_root=target_repo_root, + workflow_run_reconcile_pr_number=int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None, + workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), + workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), + ) From 0c494a4c421e84723f14d0d7ea1e9dd84e071bfd Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 05:19:32 +0900 Subject: [PATCH 086/209] refactor(reviewer-bot): extract comment application service --- scripts/reviewer_bot.py | 1 + .../reviewer_bot_lib/comment_application.py | 227 ++++++++++++++++++ scripts/reviewer_bot_lib/comment_routing.py | 201 +--------------- scripts/reviewer_bot_lib/reconcile.py | 26 +- .../test_comment_application_contract.py | 59 +++++ tests/fixtures/reconcile_harness.py | 6 +- .../test_reconcile_workflow_run.py | 12 +- .../unit/reviewer_bot/test_comment_routing.py | 4 +- 8 files changed, 327 insertions(+), 209 deletions(-) create mode 100644 scripts/reviewer_bot_lib/comment_application.py create mode 100644 tests/contract/reviewer_bot/test_comment_application_contract.py diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index ecffb9543..8199a3c5f 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -67,6 +67,7 @@ import scripts.reviewer_bot_lib.automation as automation_module import scripts.reviewer_bot_lib.commands as commands_module +import scripts.reviewer_bot_lib.comment_application as comment_application_module # noqa: F401 import scripts.reviewer_bot_lib.comment_routing as comment_routing_module import scripts.reviewer_bot_lib.events as events_module import scripts.reviewer_bot_lib.github_api as github_api_module diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py new file mode 100644 index 000000000..9f2871eae --- /dev/null +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -0,0 +1,227 @@ +"""Comment mutation and replay application helpers.""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone + +from .context import CommentEventRequest + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +def normalize_comment_body(body: str) -> str: + return "\n".join(line.rstrip() for line in body.replace("\r\n", "\n").split("\n")).strip() + + +def digest_comment_body(body: str) -> str: + return hashlib.sha256(normalize_comment_body(body).encode("utf-8")).hexdigest() + + +def record_conversation_freshness( + bot, + state: dict, + request: CommentEventRequest, +) -> bool: + issue_number = request.issue_number + review_data = bot.ensure_review_entry(state, issue_number, create=True) + if review_data is None: + return False + comment_author = request.comment_author + created_at = request.comment_created_at + semantic_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" + if request.issue_author and request.issue_author.lower() == comment_author.lower(): + return bot.reviews_module.accept_channel_event( + review_data, + "contributor_comment", + semantic_key=semantic_key, + timestamp=created_at, + actor=comment_author, + ) + current_reviewer = review_data.get("current_reviewer") + if isinstance(current_reviewer, str) and current_reviewer.lower() == comment_author.lower(): + changed = bot.reviews_module.accept_channel_event( + review_data, + "reviewer_comment", + semantic_key=semantic_key, + timestamp=created_at, + actor=comment_author, + ) + previous_activity = review_data.get("last_reviewer_activity") + previous_warning = review_data.get("transition_warning_sent") + previous_notice = review_data.get("transition_notice_sent_at") + bot.reviews_module.record_reviewer_activity(review_data, created_at) + activity_changed = ( + previous_activity != review_data.get("last_reviewer_activity") + or previous_warning != review_data.get("transition_warning_sent") + or previous_notice != review_data.get("transition_notice_sent_at") + ) + return changed or activity_changed + return False + + +def store_pending_privileged_command(review_data: dict, issue_number: int, source_event_key: str, command_name: str, actor: str, args: list[str]) -> bool: + pending = review_data.setdefault("pending_privileged_commands", {}) + pending[source_event_key] = { + "source_event_key": source_event_key, + "command_name": command_name, + "issue_number": issue_number, + "actor": actor, + "args": args, + "status": "pending", + "created_at": _now_iso(), + } + return True + + +def validate_accept_no_fls_changes_handoff( + bot, + request: CommentEventRequest, +) -> tuple[bool, dict]: + if request.is_pull_request: + return False, {"reason": "pull_request_target_not_allowed"} + labels = bot.parse_issue_labels() + if bot.FLS_AUDIT_LABEL not in labels: + return False, {"reason": "missing_fls_audit_label"} + permission_status = bot.get_user_permission_status(request.comment_author, "triage") + if permission_status == "unavailable": + return False, {"reason": "authorization_unavailable"} + if permission_status != "granted": + return False, {"reason": "authorization_failed"} + return True, { + "command_name": "accept-no-fls-changes", + "issue_number": request.issue_number, + "actor": request.comment_author, + "authorization": {"required_permission": "triage", "authorized": True}, + "target": {"kind": "issue", "number": request.issue_number, "labels": sorted(labels)}, + } + + +def apply_comment_command( + bot, + state: dict, + request: CommentEventRequest, + classified: dict, + *, + classify_issue_comment_actor, +) -> bool: + if not isinstance(classified, dict): + return False + issue_number = request.issue_number + comment_author = request.comment_author + command = classified.get("command") + args = classified.get("args") or [] + if not isinstance(command, str): + return False + actor_class = classify_issue_comment_actor(request) + if actor_class in {"unknown_actor", "bot_account", "github_app_or_other_automation"}: + return False + review_data = bot.ensure_review_entry(state, issue_number, create=True) + if review_data is None: + return False + source_event_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" + if command == "accept-no-fls-changes": + is_valid, metadata = validate_accept_no_fls_changes_handoff(bot, request) + if not is_valid: + bot.post_comment( + issue_number, + "❌ This command is not eligible for privileged handoff from the current trusted live state.", + ) + return False + stored = store_pending_privileged_command(review_data, issue_number, source_event_key, command, comment_author, list(args)) + if stored: + review_data["pending_privileged_commands"][source_event_key].update(metadata) + bot.post_comment( + issue_number, + "✅ Recorded pending privileged command `accept-no-fls-changes` from trusted live validation. Use the isolated privileged workflow to execute it from issue `#314` state.", + ) + return stored + if command == "_multiple_commands": + bot.post_comment(issue_number, f"⚠️ Multiple bot commands in one comment are ignored. Please post a single command per comment. For a list of commands, use `{bot.BOT_MENTION} /commands`.") + return False + response = "" + success = False + state_changed = False + if command == "pass": + response, success = bot.handle_pass_command(state, issue_number, comment_author, " ".join(args) if args else None) + state_changed = success + elif command == "away": + if args: + response, success = bot.handle_pass_until_command(state, issue_number, comment_author, args[0], " ".join(args[1:]) if len(args) > 1 else None) + state_changed = success + else: + response = f"❌ Missing date. Usage: `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]`" + elif command == "label": + response, success, state_changed = bot.handle_label_command(state, issue_number, " ".join(args)) + elif command == "sync-members": + response, success = bot.handle_sync_members_command(state) + state_changed = success + elif command == "queue": + response, success = bot.handle_queue_command(state) + elif command == "commands": + response, success = bot.handle_commands_command() + elif command == "claim": + response, success = bot.handle_claim_command(state, issue_number, comment_author) + state_changed = success + elif command == "release": + response, success = bot.handle_release_command(state, issue_number, comment_author, list(args)) + state_changed = success + elif command == "rectify": + response, success, state_changed = bot.handle_rectify_command(state, issue_number, comment_author) + elif command == "r?-user": + response, success = bot.handle_assign_command(state, issue_number, args[0] if args else "") + state_changed = success + elif command == "assign-from-queue": + response, success = bot.handle_assign_from_queue_command(state, issue_number) + state_changed = success + elif command == "r?": + response = f"❌ Missing target. Usage:\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Assign next reviewer from queue" + elif command == "_malformed_known": + attempted = args[0] if args else "command" + response = f"⚠️ Did you mean `{bot.BOT_MENTION} /{attempted}`?\n\nCommands require a `/` prefix." + elif command == "_malformed_unknown": + attempted = args[0] if args else "" + response = f"⚠️ Unknown command `{attempted}`. Commands require a `/` prefix.\n\nTry `{bot.BOT_MENTION} /commands` to see available commands." + else: + response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{bot.get_commands_help()}" + comment_id = request.comment_id + if comment_id > 0 and command != "_multiple_commands": + bot.add_reaction(comment_id, "eyes") + if success: + bot.add_reaction(comment_id, "+1") + if response: + bot.post_comment(issue_number, response) + return state_changed + + +def process_comment_event( + bot, + state: dict, + request: CommentEventRequest, + *, + classify_comment_payload, + classify_issue_comment_actor, +) -> bool: + comment_id = request.comment_id + comment_created_at = request.comment_created_at or _now_iso() + comment_request = CommentEventRequest(**{**request.__dict__, "comment_created_at": comment_created_at}) + classified = classify_comment_payload(bot, comment_request.comment_body) + comment_class = classified["comment_class"] + state_changed = False + if comment_class in {"plain_text", "command_plus_text"} and comment_id > 0: + state_changed = record_conversation_freshness(bot, state, comment_request) or state_changed + if comment_class in {"command_only", "command_plus_text"} and int(classified.get("command_count", 0)) == 1: + state_changed = apply_comment_command( + bot, + state, + comment_request, + classified, + classify_issue_comment_actor=classify_issue_comment_actor, + ) or state_changed + return state_changed diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 15513f813..d4856d840 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -6,6 +6,11 @@ import re from datetime import datetime, timezone +from .comment_application import ( + digest_comment_body, + normalize_comment_body, + process_comment_event, +) from .context import CommentEventRequest, PrCommentTrustContext from .event_inputs import ( build_comment_event_request as decode_comment_event_request, @@ -46,7 +51,7 @@ def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) def _normalize_comment_body(body: str) -> str: - return "\n".join(line.rstrip() for line in body.replace("\r\n", "\n").split("\n")).strip() + return normalize_comment_body(body) def _semantic_digest(value: str) -> str: @@ -54,7 +59,7 @@ def _semantic_digest(value: str) -> str: def _digest_body(body: str) -> str: - return hashlib.sha256(_normalize_comment_body(body).encode("utf-8")).hexdigest() + return digest_comment_body(body) def _comment_line_is_command(bot, line: str) -> bool: @@ -280,194 +285,14 @@ def build_pr_comment_observer_payload( ) -def _record_conversation_freshness( - bot, - state: dict, - request: CommentEventRequest, -) -> bool: - issue_number = request.issue_number - review_data = bot.ensure_review_entry(state, issue_number, create=True) - if review_data is None: - return False - comment_author = request.comment_author - created_at = request.comment_created_at - semantic_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" - if request.issue_author and request.issue_author.lower() == comment_author.lower(): - return bot.reviews_module.accept_channel_event( - review_data, - "contributor_comment", - semantic_key=semantic_key, - timestamp=created_at, - actor=comment_author, - ) - current_reviewer = review_data.get("current_reviewer") - if isinstance(current_reviewer, str) and current_reviewer.lower() == comment_author.lower(): - changed = bot.reviews_module.accept_channel_event( - review_data, - "reviewer_comment", - semantic_key=semantic_key, - timestamp=created_at, - actor=comment_author, - ) - previous_activity = review_data.get("last_reviewer_activity") - previous_warning = review_data.get("transition_warning_sent") - previous_notice = review_data.get("transition_notice_sent_at") - bot.reviews_module.record_reviewer_activity(review_data, created_at) - activity_changed = ( - previous_activity != review_data.get("last_reviewer_activity") - or previous_warning != review_data.get("transition_warning_sent") - or previous_notice != review_data.get("transition_notice_sent_at") - ) - return changed or activity_changed - return False - - -def _store_pending_privileged_command(review_data: dict, issue_number: int, source_event_key: str, command_name: str, actor: str, args: list[str]) -> bool: - pending = review_data.setdefault("pending_privileged_commands", {}) - pending[source_event_key] = { - "source_event_key": source_event_key, - "command_name": command_name, - "issue_number": issue_number, - "actor": actor, - "args": args, - "status": "pending", - "created_at": _now_iso(), - } - return True - - -def _validate_accept_no_fls_changes_handoff( - bot, - request: CommentEventRequest, -) -> tuple[bool, dict]: - if request.is_pull_request: - return False, {"reason": "pull_request_target_not_allowed"} - labels = bot.parse_issue_labels() - if bot.FLS_AUDIT_LABEL not in labels: - return False, {"reason": "missing_fls_audit_label"} - permission_status = bot.get_user_permission_status(request.comment_author, "triage") - if permission_status == "unavailable": - return False, {"reason": "authorization_unavailable"} - if permission_status != "granted": - return False, {"reason": "authorization_failed"} - return True, { - "command_name": "accept-no-fls-changes", - "issue_number": request.issue_number, - "actor": request.comment_author, - "authorization": {"required_permission": "triage", "authorized": True}, - "target": {"kind": "issue", "number": request.issue_number, "labels": sorted(labels)}, - } - - -def _handle_command( - bot, - state: dict, - request: CommentEventRequest, - classified: dict, -) -> bool: - if not isinstance(classified, dict): - return False - issue_number = request.issue_number - comment_author = request.comment_author - command = classified.get("command") - args = classified.get("args") or [] - if not isinstance(command, str): - return False - actor_class = _classify_issue_comment_actor(request) - if actor_class in {"unknown_actor", "bot_account", "github_app_or_other_automation"}: - return False - review_data = bot.ensure_review_entry(state, issue_number, create=True) - if review_data is None: - return False - source_event_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" - if command == "accept-no-fls-changes": - is_valid, metadata = _validate_accept_no_fls_changes_handoff(bot, request) - if not is_valid: - bot.post_comment( - issue_number, - "❌ This command is not eligible for privileged handoff from the current trusted live state.", - ) - return False - stored = _store_pending_privileged_command(review_data, issue_number, source_event_key, command, comment_author, list(args)) - if stored: - review_data["pending_privileged_commands"][source_event_key].update(metadata) - bot.post_comment( - issue_number, - "✅ Recorded pending privileged command `accept-no-fls-changes` from trusted live validation. Use the isolated privileged workflow to execute it from issue `#314` state.", - ) - return stored - if command == "_multiple_commands": - bot.post_comment(issue_number, f"⚠️ Multiple bot commands in one comment are ignored. Please post a single command per comment. For a list of commands, use `{bot.BOT_MENTION} /commands`.") - return False - response = "" - success = False - state_changed = False - if command == "pass": - response, success = bot.handle_pass_command(state, issue_number, comment_author, " ".join(args) if args else None) - state_changed = success - elif command == "away": - if args: - response, success = bot.handle_pass_until_command(state, issue_number, comment_author, args[0], " ".join(args[1:]) if len(args) > 1 else None) - state_changed = success - else: - response = f"❌ Missing date. Usage: `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]`" - elif command == "label": - response, success, state_changed = bot.handle_label_command(state, issue_number, " ".join(args)) - elif command == "sync-members": - response, success = bot.handle_sync_members_command(state) - state_changed = success - elif command == "queue": - response, success = bot.handle_queue_command(state) - elif command == "commands": - response, success = bot.handle_commands_command() - elif command == "claim": - response, success = bot.handle_claim_command(state, issue_number, comment_author) - state_changed = success - elif command == "release": - response, success = bot.handle_release_command(state, issue_number, comment_author, list(args)) - state_changed = success - elif command == "rectify": - response, success, state_changed = bot.handle_rectify_command(state, issue_number, comment_author) - elif command == "r?-user": - response, success = bot.handle_assign_command(state, issue_number, args[0] if args else "") - state_changed = success - elif command == "assign-from-queue": - response, success = bot.handle_assign_from_queue_command(state, issue_number) - state_changed = success - elif command == "r?": - response = f"❌ Missing target. Usage:\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Assign next reviewer from queue" - elif command == "_malformed_known": - attempted = args[0] if args else "command" - response = f"⚠️ Did you mean `{bot.BOT_MENTION} /{attempted}`?\n\nCommands require a `/` prefix." - elif command == "_malformed_unknown": - attempted = args[0] if args else "" - response = f"⚠️ Unknown command `{attempted}`. Commands require a `/` prefix.\n\nTry `{bot.BOT_MENTION} /commands` to see available commands." - else: - response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{bot.get_commands_help()}" - comment_id = request.comment_id - if comment_id > 0 and command != "_multiple_commands": - bot.add_reaction(comment_id, "eyes") - if success: - bot.add_reaction(comment_id, "+1") - if response: - bot.post_comment(issue_number, response) - return state_changed - - def _process_comment_event(bot, state: dict, request: CommentEventRequest) -> bool: - comment_id = request.comment_id - comment_created_at = request.comment_created_at or _now_iso() - comment_request = CommentEventRequest( - **{**request.__dict__, "comment_created_at": comment_created_at} + return process_comment_event( + bot, + state, + request, + classify_comment_payload=classify_comment_payload, + classify_issue_comment_actor=_classify_issue_comment_actor, ) - classified = classify_comment_payload(bot, comment_request.comment_body) - comment_class = classified["comment_class"] - state_changed = False - if comment_class in {"plain_text", "command_plus_text"} and comment_id > 0: - state_changed = _record_conversation_freshness(bot, state, comment_request) or state_changed - if comment_class in {"command_only", "command_plus_text"} and int(classified.get("command_count", 0)) == 1: - state_changed = _handle_command(bot, state, comment_request, classified) or state_changed - return state_changed def handle_comment_event( diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index d3f187c01..9d4319ed4 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -5,12 +5,12 @@ from copy import deepcopy from dataclasses import dataclass -from .comment_routing import ( - _digest_body, - _handle_command, - _record_conversation_freshness, - classify_comment_payload, +from .comment_application import ( + digest_comment_body, + process_comment_event, + record_conversation_freshness, ) +from .comment_routing import classify_comment_payload, classify_issue_comment_actor from .context import CommentEventRequest from .reviews import ( find_triage_approval_after, @@ -674,7 +674,7 @@ def replay_request(comment_context: LiveCommentReplayContext | None = None, *, c except ReconcileReadError as exc: changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, replay_request()) + changed = record_conversation_freshness(bot, state, replay_request()) if exc.failure_kind == "not_found": summary = ( f"Deferred comment {comment_id} is no longer visible; source-time freshness only may be preserved. " @@ -698,15 +698,15 @@ def replay_request(comment_context: LiveCommentReplayContext | None = None, *, c live_body = live_comment.get("body") if not isinstance(live_body, str): raise RuntimeError("Live deferred comment body is unavailable") - if _digest_body(live_body) != payload.get("source_body_digest"): + if digest_comment_body(live_body) != payload.get("source_body_digest"): changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) + changed = record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) gap_changed = _update_deferred_gap(bot, review_data, payload, "reconcile_failed_closed", f"Deferred comment {comment_id} body digest changed; command execution suppressed. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.") return changed or gap_changed changed = False if source_freshness_eligible: - changed = _record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) or changed + changed = record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body)) or changed validation_result = _validate_live_comment_replay_contract( bot, review_data, @@ -717,7 +717,13 @@ def replay_request(comment_context: LiveCommentReplayContext | None = None, *, c return changed or validation_result.changed live_classified = validation_result.live_classified if context.payload.comment_class in {"command_only", "command_plus_text"}: - changed = _handle_command(bot, state, replay_request(comment_context, comment_body=live_body), live_classified) or changed + changed = process_comment_event( + bot, + state, + replay_request(comment_context, comment_body=live_body), + classify_comment_payload=lambda _bot, _body: live_classified, + classify_issue_comment_actor=classify_issue_comment_actor, + ) or changed reconciled_changed = _mark_reconciled_source_event(review_data, str(payload.get("source_event_key", ""))) gap_cleared_changed = _clear_source_event_key(review_data, str(payload.get("source_event_key", ""))) return changed or reconciled_changed or gap_cleared_changed diff --git a/tests/contract/reviewer_bot/test_comment_application_contract.py b/tests/contract/reviewer_bot/test_comment_application_contract.py new file mode 100644 index 000000000..20ead9684 --- /dev/null +++ b/tests/contract/reviewer_bot/test_comment_application_contract.py @@ -0,0 +1,59 @@ +import pytest + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_application +from tests.fixtures.comment_routing_harness import CommentRoutingHarness +from tests.fixtures.reviewer_bot import make_state + +pytestmark = pytest.mark.contract + + +def test_comment_application_digest_is_stable_for_replay_identity(): + assert comment_application.digest_comment_body("hello\r\nworld\n") == comment_application.digest_comment_body("hello\nworld") + + +def test_comment_application_records_contributor_freshness_from_typed_request(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_author="dana", + comment_author="dana", + comment_body="plain text", + ) + + changed = comment_application.record_conversation_freshness(reviewer_bot, state, request) + + assert changed is True + assert review["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:100" + + +def test_comment_application_stores_pending_privileged_command_from_typed_request(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + state = make_state() + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + request = harness.request( + issue_number=42, + is_pull_request=False, + issue_author="dana", + comment_author="dana", + comment_body="@guidelines-bot /accept-no-fls-changes", + ) + harness.runtime.parse_issue_labels = lambda: [reviewer_bot.FLS_AUDIT_LABEL] + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" + harness.runtime.post_comment = lambda issue_number, body: True + + changed = comment_application.process_comment_event( + harness.runtime, + state, + request, + classify_comment_payload=reviewer_bot.comment_routing_module.classify_comment_payload, + classify_issue_comment_actor=reviewer_bot.comment_routing_module.classify_issue_comment_actor, + ) + + assert changed is True + assert review["pending_privileged_commands"]["issue_comment:100"]["command_name"] == "accept-no-fls-changes" diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index 5a1335b04..af5f6b4b0 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing +from scripts.reviewer_bot_lib import comment_application from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import pull_request_payload, review_payload @@ -67,7 +67,7 @@ def issue_comment_payload( "comment_id": comment_id, "comment_class": comment_class, "has_non_command_text": has_non_command_text, - "source_body_digest": comment_routing._digest_body(body), + "source_body_digest": comment_application.digest_comment_body(body), "source_created_at": source_created_at, "actor_login": actor_login, } @@ -103,7 +103,7 @@ def review_comment_payload( "comment_id": comment_id, "comment_class": comment_class, "has_non_command_text": has_non_command_text, - "source_body_digest": comment_routing._digest_body(body), + "source_body_digest": comment_application.digest_comment_body(body), "source_created_at": source_created_at, "actor_login": actor_login, "actor_id": actor_id, diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 95846e2ba..070593dbd 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -113,7 +113,7 @@ def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations( author_type="User", author_association="MEMBER", ) - monkeypatch.setattr(reviewer_bot.reconcile_module, "_handle_command", lambda *args, **kwargs: False) + monkeypatch.setattr(reviewer_bot.comment_application_module, "apply_comment_command", lambda *args, **kwargs: False) assert harness.run(state) is True assert "issue_comment:210" in review["reconciled_source_events"] @@ -421,8 +421,8 @@ def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguou ) command_calls = [] monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_handle_command", + reviewer_bot.comment_application_module, + "apply_comment_command", lambda *args, **kwargs: command_calls.append("called") or True, ) @@ -502,9 +502,9 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" claim_contexts = [] monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_handle_command", - lambda bot, state_obj, request, classified: claim_contexts.append( + reviewer_bot.comment_application_module, + "apply_comment_command", + lambda bot, state_obj, request, classified, classify_issue_comment_actor=None: claim_contexts.append( { "issue_number": request.issue_number, "username": request.comment_author, diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index c7d641b45..411399eb2 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -1,7 +1,7 @@ import pytest from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing +from scripts.reviewer_bot_lib import comment_application, comment_routing from scripts.reviewer_bot_lib.context import CommentEventRequest from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -34,7 +34,7 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_ comment_source_event_key="issue_comment:100", ) - changed = reviewer_bot.comment_routing_module._record_conversation_freshness( + changed = comment_application.record_conversation_freshness( reviewer_bot, state, request, From 46ffd5e3afd028a29bc38f38222be02159ea5267 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 05:58:13 +0900 Subject: [PATCH 087/209] refactor(reviewer-bot): extract public review state surface --- scripts/reviewer_bot.py | 12 +- scripts/reviewer_bot_lib/lifecycle.py | 32 +- scripts/reviewer_bot_lib/maintenance.py | 8 +- scripts/reviewer_bot_lib/overdue.py | 3 +- scripts/reviewer_bot_lib/reconcile.py | 10 +- scripts/reviewer_bot_lib/review_state.py | 354 ++++++++++++++++++ scripts/reviewer_bot_lib/reviews.py | 5 +- scripts/reviewer_bot_lib/sweeper.py | 20 +- .../test_review_state_contract.py | 63 ++++ tests/fixtures/fake_runtime.py | 54 ++- tests/unit/reviewer_bot/test_lifecycle.py | 2 +- tests/unit/reviewer_bot/test_sweeper_logic.py | 6 +- 12 files changed, 529 insertions(+), 40 deletions(-) create mode 100644 scripts/reviewer_bot_lib/review_state.py create mode 100644 tests/contract/reviewer_bot/test_review_state_contract.py diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 8199a3c5f..4eb9d1078 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -76,8 +76,10 @@ import scripts.reviewer_bot_lib.maintenance as maintenance_module import scripts.reviewer_bot_lib.project_board as project_board_module import scripts.reviewer_bot_lib.reconcile as reconcile_module +import scripts.reviewer_bot_lib.review_state as review_state_module import scripts.reviewer_bot_lib.reviews as reviews_module import scripts.reviewer_bot_lib.state_store as state_store_module +import scripts.reviewer_bot_lib.sweeper as sweeper_module # noqa: F401 from scripts.reviewer_bot_lib import app as app_module # Exported runtime config surface used by extracted modules and tests. @@ -782,16 +784,16 @@ def handle_assign_from_queue_command(state: dict, issue_number: int) -> tuple[st def ensure_review_entry(state: dict, issue_number: int, create: bool = False) -> dict | None: - return reviews_module.ensure_review_entry(state, issue_number, create=create) + return review_state_module.ensure_review_entry(state, issue_number, create=create) def set_current_reviewer(state: dict, issue_number: int, reviewer: str, - assignment_method: str = "round-robin") -> None: - reviews_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + assignment_method: str = "round-robin") -> None: + review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: - return reviews_module.update_reviewer_activity(state, issue_number, reviewer) + return review_state_module.update_reviewer_activity(state, issue_number, reviewer) def mark_review_complete( @@ -800,7 +802,7 @@ def mark_review_complete( reviewer: str | None, source: str, ) -> bool: - return reviews_module.mark_review_complete(state, issue_number, reviewer, source) + return review_state_module.mark_review_complete(state, issue_number, reviewer, source) def is_triage_or_higher(username: str) -> bool: diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 8168d7387..50db5c047 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -10,6 +10,14 @@ from datetime import datetime, timezone from .guidance import get_fls_audit_guidance, get_issue_guidance, get_pr_guidance +from .review_state import ( + accept_channel_event, + ensure_review_entry, + mark_review_complete, + record_transition_notice_sent, + set_current_reviewer, +) +from .reviews import rebuild_pr_approval_state @dataclass(frozen=True) @@ -44,7 +52,7 @@ def _semantic_digest(value: str) -> str: def handle_transition_notice(bot, state: dict, issue_number: int, reviewer: str) -> bool: - review_data = bot.ensure_review_entry(state, issue_number, create=True) + review_data = ensure_review_entry(state, issue_number, create=True) if review_data is None: return False if review_data.get("transition_notice_sent_at"): @@ -60,7 +68,7 @@ def handle_transition_notice(bot, state: dict, issue_number: int, reviewer: str) _If you believe this is in error or have extenuating circumstances, please reach out to the subcommittee._""" if not bot.post_comment(issue_number, notice_message): return False - bot.reviews_module.record_transition_notice_sent( + record_transition_notice_sent( review_data, bot.datetime.now(bot.timezone.utc).isoformat(), ) @@ -100,8 +108,8 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: return False is_pr = _is_pr_event() assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) - bot.set_current_reviewer(state, issue_number, reviewer) - review_data = bot.ensure_review_entry(state, issue_number, create=True) + set_current_reviewer(state, issue_number, reviewer) + review_data = ensure_review_entry(state, issue_number, create=True) if is_pr and isinstance(review_data, dict): head_sha = os.environ.get("PR_HEAD_SHA", "").strip() if head_sha: @@ -130,7 +138,7 @@ def handle_issue_edited_event(bot, state: dict) -> bool: editor = os.environ.get("SENDER_LOGIN", "").strip() or issue_author if not issue_author or editor.lower() != issue_author.lower(): return False - review_data = bot.ensure_review_entry(state, issue_number) + review_data = ensure_review_entry(state, issue_number) if review_data is None: return False updated_at = os.environ.get("ISSUE_UPDATED_AT", "").strip() or _now_iso() @@ -148,7 +156,7 @@ def handle_issue_edited_event(bot, state: dict) -> bool: semantic_key = f"issues_edit_title:{issue_number}:{_semantic_digest(current_title)}" else: semantic_key = f"issues_edit_body:{issue_number}:{_semantic_digest(current_body)}" - return bot.reviews_module.accept_channel_event( + return accept_channel_event( review_data, "contributor_comment", semantic_key=semantic_key, @@ -169,9 +177,9 @@ def handle_labeled_event(bot, state: dict) -> bool: if label_name == "sign-off: create pr": if is_pr: return False - review_data = bot.ensure_review_entry(state, issue_number) + review_data = ensure_review_entry(state, issue_number) reviewer = review_data.get("current_reviewer") if review_data else None - return bot.mark_review_complete(state, issue_number, reviewer, "issue_label: sign-off: create pr") + return mark_review_complete(state, issue_number, reviewer, "issue_label: sign-off: create pr") if label_name not in bot.REVIEW_LABELS: return False return handle_issue_or_pr_opened(bot, state) @@ -185,7 +193,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) if not issue_number: return False - review_data = bot.ensure_review_entry(state, issue_number) + review_data = ensure_review_entry(state, issue_number) if review_data is None or not review_data.get("current_reviewer"): return False head_sha = os.environ.get("PR_HEAD_SHA", "").strip() @@ -200,7 +208,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: previous_review_completion_source = review_data.get("review_completion_source") review_data["active_head_sha"] = head_sha timestamp = os.environ.get("EVENT_CREATED_AT", "") or _now_iso() - changed = bot.reviews_module.accept_channel_event( + changed = accept_channel_event( review_data, "contributor_revision", semantic_key=f"pull_request_sync:{issue_number}:{head_sha}", @@ -208,7 +216,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: reviewed_head_sha=head_sha, source_precedence=1, ) - bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data) + rebuild_pr_approval_state(bot, issue_number, review_data) approval_changed = ( previous_completion != review_data.get("current_cycle_completion") or previous_write_approval != review_data.get("current_cycle_write_approval") @@ -282,7 +290,7 @@ def maybe_record_head_observation_repair(bot, issue_number: int, review_data: di if isinstance(contributor_revision, dict) and contributor_revision.get("reviewed_head_sha") == head_sha: review_data["active_head_sha"] = head_sha return HeadObservationRepairResult(changed=True, outcome="changed") - changed = bot.reviews_module.accept_channel_event( + changed = accept_channel_event( review_data, "contributor_revision", semantic_key=f"pull_request_head_observed:{issue_number}:{head_sha}", diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index e80f2a11b..f21eaf8f1 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -18,6 +18,10 @@ preview_board_projection_for_item, reviewer_board_preflight, ) +from .review_state import ( + list_open_tracked_review_items, + repair_missing_reviewer_review_state, +) from .sweeper import sweep_deferred_gaps @@ -76,7 +80,7 @@ def status_projection_repair_needed(bot, state: dict) -> bool: 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)) + numbers.update(list_open_tracked_review_items(state)) return sorted(number for number in numbers if isinstance(number, int) and number > 0) @@ -208,7 +212,7 @@ def handle_scheduled_check(bot, state: dict) -> bool: if not isinstance(issue_snapshot, dict) or not isinstance(issue_snapshot.get("pull_request"), dict): continue try: - if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data): + if repair_missing_reviewer_review_state(bot, issue_number, review_data): changed = True bot.collect_touched_item(issue_number) if _clear_maintenance_repair_marker(review_data, "review_repair"): diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index 4d5a3c9e2..57a6d710c 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -31,8 +31,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: print(f"WARNING: Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable") continue if isinstance(issue_snapshot.get("pull_request"), dict): - response_state = bot.reviews_module.compute_reviewer_response_state( - bot, + response_state = bot.compute_reviewer_response_state( issue_number, review_data, issue_snapshot=issue_snapshot, diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 9d4319ed4..1214f5627 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -12,8 +12,10 @@ ) from .comment_routing import classify_comment_payload, classify_issue_comment_actor from .context import CommentEventRequest +from .review_state import accept_channel_event, record_reviewer_activity from .reviews import ( find_triage_approval_after, + rebuild_pr_approval_state_result, refresh_reviewer_review_from_live_preferred_review, ) @@ -263,7 +265,7 @@ def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dic reviews=reviews, actor=review_data.get("current_reviewer"), ) - approval_result = bot.reviews_module.rebuild_pr_approval_state_result( + approval_result = rebuild_pr_approval_state_result( bot, issue_number, review_data, @@ -905,7 +907,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: actor = context.actor_login state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): - bot.reviews_module.accept_channel_event( + accept_channel_event( review_data, "reviewer_review", semantic_key=source_event_key, @@ -914,7 +916,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: reviewed_head_sha=live_commit_id, source_precedence=1, ) - bot.reviews_module.record_reviewer_activity(review_data, live_submitted_at) + record_reviewer_activity(review_data, live_submitted_at) state_changed = True if _record_review_rebuild(bot, state, pr_number, review_data): state_changed = True @@ -928,7 +930,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: parsed_payload, expected_event_action="dismissed", ) - bot.reviews_module.accept_channel_event( + accept_channel_event( review_data, "review_dismissal", semantic_key=source_event_key, diff --git a/scripts/reviewer_bot_lib/review_state.py b/scripts/reviewer_bot_lib/review_state.py new file mode 100644 index 000000000..7bb01a8d5 --- /dev/null +++ b/scripts/reviewer_bot_lib/review_state.py @@ -0,0 +1,354 @@ +"""Public mutable review-state operations shared across reviewer-bot modules.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from .reviews import ( + build_reviewer_review_record_from_live_review, + get_preferred_current_reviewer_review_for_cycle, + parse_github_timestamp, +) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _ensure_channel_map(review_entry: dict, name: str) -> dict: + value = review_entry.get(name) + if not isinstance(value, dict): + value = {"accepted": None, "seen_keys": []} + review_entry[name] = value + if not isinstance(value.get("seen_keys"), list): + value["seen_keys"] = [] + return value + + +def _ensure_dict(review_entry: dict, name: str) -> dict: + value = review_entry.get(name) + if not isinstance(value, dict): + value = {} + review_entry[name] = value + return value + + +def ensure_review_entry(state: dict, issue_number: int, create: bool = False) -> dict | None: + issue_key = str(issue_number) + if "active_reviews" not in state or not isinstance(state.get("active_reviews"), dict): + state["active_reviews"] = {} + review_entry = state["active_reviews"].get(issue_key) + if review_entry is None: + if not create: + return None + review_entry = {} + state["active_reviews"][issue_key] = review_entry + elif isinstance(review_entry, list): + review_entry = {"skipped": review_entry} + state["active_reviews"][issue_key] = review_entry + if not isinstance(review_entry, dict): + return None + + defaults: dict[str, Any] = { + "skipped": [], + "current_reviewer": None, + "cycle_started_at": None, + "active_cycle_started_at": None, + "assigned_at": None, + "active_head_sha": None, + "last_reviewer_activity": None, + "transition_warning_sent": None, + "transition_notice_sent_at": None, + "assignment_method": None, + "review_completed_at": None, + "review_completed_by": None, + "review_completion_source": None, + "mandatory_approver_required": False, + "mandatory_approver_label_applied_at": None, + "mandatory_approver_pinged_at": None, + "mandatory_approver_satisfied_by": None, + "mandatory_approver_satisfied_at": None, + "repair_needed": None, + "overdue_anchor": None, + } + for field, default in defaults.items(): + if field not in review_entry: + review_entry[field] = default + if not isinstance(review_entry.get("skipped"), list): + review_entry["skipped"] = [] + + for channel in ( + "reviewer_comment", + "reviewer_review", + "contributor_comment", + "contributor_revision", + "review_dismissal", + ): + _ensure_channel_map(review_entry, channel) + for mapping in ( + "deferred_gaps", + "observer_discovery_watermarks", + "pending_privileged_commands", + "current_cycle_completion", + "current_cycle_write_approval", + ): + _ensure_dict(review_entry, mapping) + reconciled_source_events = review_entry.get("reconciled_source_events") + if not isinstance(reconciled_source_events, list): + review_entry["reconciled_source_events"] = [] + return review_entry + + +def _reset_cycle_state(review_data: dict) -> None: + for channel in ( + "reviewer_comment", + "reviewer_review", + "contributor_comment", + "contributor_revision", + "review_dismissal", + ): + review_data[channel] = {"accepted": None, "seen_keys": []} + review_data["current_cycle_completion"] = {} + review_data["current_cycle_write_approval"] = {} + review_data["overdue_anchor"] = None + if isinstance(review_data.get("pending_privileged_commands"), dict): + review_data["pending_privileged_commands"] = {} + + +def clear_transition_timers(review_data: dict) -> None: + review_data["transition_warning_sent"] = None + review_data["transition_notice_sent_at"] = None + + +def record_reviewer_activity(review_data: dict, timestamp: str) -> bool: + current = parse_github_timestamp(review_data.get("last_reviewer_activity")) + candidate = parse_github_timestamp(timestamp) + if candidate is None: + return False + if current is None or candidate > current: + review_data["last_reviewer_activity"] = timestamp + clear_transition_timers(review_data) + return True + return False + + +def record_transition_notice_sent(review_data: dict, timestamp: str) -> None: + review_data["transition_notice_sent_at"] = timestamp + + +def set_current_reviewer( + state: dict, + issue_number: int, + reviewer: str, + assignment_method: str = "round-robin", +) -> None: + now = _now_iso() + review_data = ensure_review_entry(state, issue_number, create=True) + if review_data is None: + return + review_data["current_reviewer"] = reviewer + review_data["cycle_started_at"] = now + review_data["active_cycle_started_at"] = now + review_data["assigned_at"] = now + record_reviewer_activity(review_data, now) + review_data["assignment_method"] = assignment_method + review_data["review_completed_at"] = None + review_data["review_completed_by"] = None + review_data["review_completion_source"] = None + review_data["mandatory_approver_required"] = False + review_data["mandatory_approver_label_applied_at"] = None + review_data["mandatory_approver_pinged_at"] = None + review_data["mandatory_approver_satisfied_by"] = None + review_data["mandatory_approver_satisfied_at"] = None + review_data["active_head_sha"] = None + _reset_cycle_state(review_data) + + +def semantic_key_seen(review_data: dict, channel_name: str, semantic_key: str) -> bool: + channel = _ensure_channel_map(review_data, channel_name) + return semantic_key in channel["seen_keys"] + + +def _compare_records(left: dict | None, right: dict | None) -> int: + if right is None: + return 1 + if left is None: + return -1 + left_time = parse_github_timestamp(left.get("timestamp")) or datetime.min.replace(tzinfo=timezone.utc) + right_time = parse_github_timestamp(right.get("timestamp")) or datetime.min.replace(tzinfo=timezone.utc) + left_rank = int(left.get("source_precedence", 0)) + right_rank = int(right.get("source_precedence", 0)) + left_key = str(left.get("semantic_key", "")) + right_key = str(right.get("semantic_key", "")) + left_tuple = (left_time, left_rank, left_key) + right_tuple = (right_time, right_rank, right_key) + if left_tuple > right_tuple: + return 1 + if left_tuple < right_tuple: + return -1 + return 0 + + +def accept_channel_event( + review_data: dict, + channel_name: str, + *, + semantic_key: str, + timestamp: str, + actor: str | None = None, + reviewed_head_sha: str | None = None, + source_precedence: int = 0, + payload: dict | None = None, + dismissal_only: bool = False, +) -> bool: + channel = _ensure_channel_map(review_data, channel_name) + if semantic_key in channel["seen_keys"]: + return False + channel["seen_keys"].append(semantic_key) + if dismissal_only: + channel["accepted"] = channel.get("accepted") or { + "semantic_key": semantic_key, + "timestamp": timestamp, + } + return True + candidate = { + "semantic_key": semantic_key, + "timestamp": timestamp, + "actor": actor, + "reviewed_head_sha": reviewed_head_sha, + "source_precedence": source_precedence, + "payload": payload or {}, + } + current = channel.get("accepted") + if _compare_records(candidate, current) >= 0: + channel["accepted"] = candidate + return True + + +def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: + review_data = ensure_review_entry(state, issue_number) + if review_data is None: + return False + current_reviewer = review_data.get("current_reviewer") + if not isinstance(current_reviewer, str) or current_reviewer.lower() != reviewer.lower(): + return False + record_reviewer_activity(review_data, _now_iso()) + return True + + +def mark_review_complete(state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: + review_data = ensure_review_entry(state, issue_number, create=True) + if review_data is None: + return False + now = _now_iso() + review_data["review_completed_at"] = now + review_data["review_completed_by"] = reviewer or None + review_data["review_completion_source"] = source + record_reviewer_activity(review_data, now) + review_data["current_cycle_completion"] = { + "completed": True, + "completed_at": now, + "source": source, + "reviewer": reviewer, + } + return True + + +def get_current_cycle_boundary(bot, review_data: dict) -> datetime | None: + for field in ("active_cycle_started_at", "cycle_started_at", "assigned_at"): + boundary = bot.parse_iso8601_timestamp(review_data.get(field)) + if boundary is not None: + return boundary + return None + + +def accept_reviewer_review_from_live_review(review_data: dict, review: dict, *, actor: str | None = None) -> bool: + record = build_reviewer_review_record_from_live_review(review, actor=actor) + if record is None: + return False + return accept_channel_event( + review_data, + "reviewer_review", + semantic_key=record["semantic_key"], + timestamp=record["timestamp"], + actor=record["actor"], + reviewed_head_sha=record["reviewed_head_sha"], + source_precedence=record["source_precedence"], + payload=record["payload"], + ) + + +def refresh_reviewer_review_from_live_preferred_review( + bot, + issue_number: int, + review_data: dict, + *, + pull_request: dict | None = None, + reviews: list[dict] | None = None, + actor: str | None = None, +) -> tuple[bool, dict | None]: + preferred_review = get_preferred_current_reviewer_review_for_cycle( + bot, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + if preferred_review is None: + return False, None + record = build_reviewer_review_record_from_live_review(preferred_review, actor=actor or review_data.get("current_reviewer")) + if record is None: + return False, None + channel = _ensure_channel_map(review_data, "reviewer_review") + changed = False + if record["semantic_key"] not in channel["seen_keys"]: + channel["seen_keys"].append(record["semantic_key"]) + changed = True + if channel.get("accepted") != record: + channel["accepted"] = record + changed = True + submitted_at = preferred_review.get("submitted_at") + if isinstance(submitted_at, str): + previous_activity = review_data.get("last_reviewer_activity") + previous_warning = review_data.get("transition_warning_sent") + previous_notice = review_data.get("transition_notice_sent_at") + record_reviewer_activity(review_data, submitted_at) + activity_changed = ( + previous_activity != review_data.get("last_reviewer_activity") + or previous_warning != review_data.get("transition_warning_sent") + or previous_notice != review_data.get("transition_notice_sent_at") + ) + changed = changed or activity_changed + return changed, preferred_review + + +def repair_missing_reviewer_review_state(bot, issue_number: int, review_data: dict, *, reviews: list[dict] | None = None) -> bool: + changed, _ = refresh_reviewer_review_from_live_preferred_review( + bot, + issue_number, + review_data, + reviews=reviews, + actor=review_data.get("current_reviewer"), + ) + return changed + + +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) diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index c61005954..76fb251a3 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -396,8 +396,9 @@ def resolve_pr_approval_state( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> tuple[dict | None, dict | None, str | None]: - if not _is_canonical_callable(bot.reviews_module.rebuild_pr_approval_state): - completion, write_approval = bot.reviews_module.rebuild_pr_approval_state( + rebuild_fn = getattr(bot, "rebuild_pr_approval_state", rebuild_pr_approval_state) + if not _is_canonical_callable(rebuild_fn): + completion, write_approval = rebuild_fn( bot, issue_number, review_data, diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index 52501d69d..b654ff41a 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -22,6 +22,14 @@ _update_deferred_gap, _was_reconciled_source_event, ) +from .review_state import ( + accept_reviewer_review_from_live_review, + get_current_cycle_boundary, + record_reviewer_activity, + refresh_reviewer_review_from_live_preferred_review, + semantic_key_seen, +) +from .reviews import rebuild_pr_approval_state def _now() -> datetime: @@ -560,7 +568,7 @@ def _can_repair_visible_review(bot, review_data: dict, review: dict, source_even return None if not isinstance(submitted_at, str): return None - boundary = bot.reviews_module.get_current_cycle_boundary(bot, review_data) + boundary = get_current_cycle_boundary(bot, review_data) submitted_dt = parse_timestamp(submitted_at) if boundary is None or submitted_dt is None or submitted_dt < boundary: return None @@ -574,15 +582,15 @@ def _repair_visible_review_gap(bot, review_data: dict, issue_number: int, source if repair is None: return False author, submitted_at, commit_id = repair - changed = bot.reviews_module.accept_reviewer_review_from_live_review(review_data, review, actor=author) - changed = bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( + changed = accept_reviewer_review_from_live_review(review_data, review, actor=author) + changed = refresh_reviewer_review_from_live_preferred_review( bot, issue_number, review_data, actor=author, )[0] or changed - bot.reviews_module.record_reviewer_activity(review_data, submitted_at) - completion, _ = bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data) + record_reviewer_activity(review_data, submitted_at) + completion, _ = rebuild_pr_approval_state(bot, issue_number, review_data) reconciled_changed = _mark_reconciled_source_event(review_data, source_event_key) gap_cleared_changed = _clear_source_event_key(review_data, source_event_key) return changed or completion is not None or reconciled_changed or gap_cleared_changed @@ -795,7 +803,7 @@ def _should_skip_discovered_key(bot, review_data: dict, source_event_key: str, c "reconcile_failed_closed", }: return False - return any(bot.reviews_module._semantic_key_seen(review_data, channel, source_event_key) for channel in channels) + return any(semantic_key_seen(review_data, channel, source_event_key) for channel in channels) def sweep_deferred_gaps(bot, state: dict) -> bool: diff --git a/tests/contract/reviewer_bot/test_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py new file mode 100644 index 000000000..29861c943 --- /dev/null +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -0,0 +1,63 @@ +import pytest + +from scripts.reviewer_bot_lib import review_state +from tests.fixtures.reviewer_bot import make_state + +pytestmark = pytest.mark.contract + + +def test_ensure_review_entry_initializes_tracked_review_shape(): + state = make_state() + + review = review_state.ensure_review_entry(state, 42, create=True) + + assert review is not None + assert review["current_reviewer"] is None + assert review["reviewer_comment"]["accepted"] is None + assert review["pending_privileged_commands"] == {} + + +def test_accept_channel_event_deduplicates_semantic_keys(): + state = make_state() + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + + first = review_state.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + second = review_state.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T10:00:00Z", + actor="alice", + ) + + assert first is True + assert second is False + + +def test_mark_review_complete_updates_completion_fields(): + state = make_state() + review_state.ensure_review_entry(state, 42, create=True) + + changed = review_state.mark_review_complete(state, 42, "alice", "unit-test") + + assert changed is True + review = state["active_reviews"]["42"] + assert review["review_completed_by"] == "alice" + assert review["review_completion_source"] == "unit-test" + assert review["current_cycle_completion"]["completed"] is True + + +def test_list_open_tracked_review_items_returns_only_assigned_entries(): + state = make_state() + review_state.ensure_review_entry(state, 42, create=True) + review_state.ensure_review_entry(state, 99, create=True) + state["active_reviews"]["42"]["current_reviewer"] = "alice" + + assert review_state.list_open_tracked_review_items(state) == [42] diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index fecb3ad7b..4900d4ff6 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -13,6 +13,7 @@ from scripts.reviewer_bot_lib import maintenance as maintenance_module from scripts.reviewer_bot_lib import queue as queue_module from scripts.reviewer_bot_lib import reconcile as reconcile_module +from scripts.reviewer_bot_lib import review_state as review_state_module from scripts.reviewer_bot_lib import reviews as reviews_module from scripts.reviewer_bot_lib import state_store as state_store_module from scripts.reviewer_bot_lib.config import ( @@ -172,6 +173,7 @@ class FakeReviewerBotRuntime: REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH AssignmentAttempt = AssignmentAttempt reviews_module = reviews_module + review_state_module = review_state_module EVENT_INTENT_MUTATING = EVENT_INTENT_MUTATING EVENT_INTENT_NON_MUTATING_DEFER = EVENT_INTENT_NON_MUTATING_DEFER EVENT_INTENT_NON_MUTATING_READONLY = EVENT_INTENT_NON_MUTATING_READONLY @@ -252,7 +254,7 @@ def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._sync_status_labels(state, issue_numbers) def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): - return reviews_module.ensure_review_entry(state, issue_number, create=create) + return review_state_module.ensure_review_entry(state, issue_number, create=create) def get_issue_assignees(self, issue_number: int): return github_api_module.get_issue_assignees(self, issue_number) @@ -304,10 +306,47 @@ def maybe_record_head_observation_repair(self, issue_number: int, review_data: d return lifecycle_module.maybe_record_head_observation_repair(self, issue_number, review_data) def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str | None = None) -> None: - return reviews_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + return review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: - return reviews_module.mark_review_complete(state, issue_number, reviewer, source) + return review_state_module.mark_review_complete(state, issue_number, reviewer, source) + + def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: + return review_state_module.update_reviewer_activity(state, issue_number, reviewer) + + def record_transition_notice_sent(self, review_data: dict, timestamp: str) -> None: + return review_state_module.record_transition_notice_sent(review_data, timestamp) + + def accept_channel_event(self, review_data: dict, channel_name: str, **kwargs) -> bool: + return review_state_module.accept_channel_event(review_data, channel_name, **kwargs) + + def record_reviewer_activity(self, review_data: dict, timestamp: str) -> bool: + return review_state_module.record_reviewer_activity(review_data, timestamp) + + def get_current_cycle_boundary(self, review_data: dict): + return review_state_module.get_current_cycle_boundary(self, review_data) + + def accept_reviewer_review_from_live_review(self, review_data: dict, review: dict, *, actor: str | None = None) -> bool: + return review_state_module.accept_reviewer_review_from_live_review(review_data, review, actor=actor) + + def refresh_reviewer_review_from_live_preferred_review(self, issue_number: int, review_data: dict, *, pull_request=None, reviews=None, actor: str | None = None): + return review_state_module.refresh_reviewer_review_from_live_preferred_review( + self, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + actor=actor, + ) + + def repair_missing_reviewer_review_state(self, issue_number: int, review_data: dict, *, reviews=None) -> bool: + return review_state_module.repair_missing_reviewer_review_state(self, issue_number, review_data, reviews=reviews) + + def list_open_tracked_review_items(self, state: dict) -> list[int]: + return review_state_module.list_open_tracked_review_items(state) + + def semantic_key_seen(self, review_data: dict, channel_name: str, semantic_key: str) -> bool: + return review_state_module.semantic_key_seen(review_data, channel_name, semantic_key) def get_next_reviewer(self, state: dict, skip_usernames=None): return queue_module.get_next_reviewer(state, skip_usernames) @@ -330,6 +369,15 @@ def parse_iso8601_timestamp(self, value: Any): def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): return reviews_module.compute_reviewer_response_state(self, issue_number, state, issue_snapshot=issue_snapshot) + def rebuild_pr_approval_state(self, issue_number: int, review_data: dict, *, pull_request=None, reviews=None): + return reviews_module.rebuild_pr_approval_state( + self, + issue_number, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: if prefer_board_token: token = self.get_config_value("REVIEWER_BOARD_TOKEN") diff --git a/tests/unit/reviewer_bot/test_lifecycle.py b/tests/unit/reviewer_bot/test_lifecycle.py index 9fe9295e7..ba98b252b 100644 --- a/tests/unit/reviewer_bot/test_lifecycle.py +++ b/tests/unit/reviewer_bot/test_lifecycle.py @@ -317,7 +317,7 @@ def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypa ), ) monkeypatch.setattr( - reviewer_bot.reviews_module, + reviewer_bot.lifecycle_module, "accept_channel_event", lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True, ) diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 69e78e62f..56157fdc6 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -220,17 +220,17 @@ def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(m review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} monkeypatch.setattr( - reviewer_bot.reviews_module, + reviewer_bot.sweeper_module, "accept_reviewer_review_from_live_review", lambda review_data, live_review, actor=None: False, ) monkeypatch.setattr( - reviewer_bot.reviews_module, + reviewer_bot.sweeper_module, "refresh_reviewer_review_from_live_preferred_review", lambda bot, issue_number, review_data, actor=None: (False, None), ) monkeypatch.setattr( - reviewer_bot.reviews_module, + reviewer_bot.sweeper_module, "rebuild_pr_approval_state", lambda bot, issue_number, review_data: (None, None), ) From 023e2b030d35473928b202953a7c9d91990f836d Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 05:59:16 +0900 Subject: [PATCH 088/209] refactor(reviewer-bot): route comment application through review state --- scripts/reviewer_bot_lib/comment_application.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py index 9f2871eae..11a36566d 100644 --- a/scripts/reviewer_bot_lib/comment_application.py +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -6,6 +6,11 @@ from datetime import datetime, timezone from .context import CommentEventRequest +from .review_state import ( + accept_channel_event, + ensure_review_entry, + record_reviewer_activity, +) def _now() -> datetime: @@ -30,14 +35,14 @@ def record_conversation_freshness( request: CommentEventRequest, ) -> bool: issue_number = request.issue_number - review_data = bot.ensure_review_entry(state, issue_number, create=True) + review_data = ensure_review_entry(state, issue_number, create=True) if review_data is None: return False comment_author = request.comment_author created_at = request.comment_created_at semantic_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" if request.issue_author and request.issue_author.lower() == comment_author.lower(): - return bot.reviews_module.accept_channel_event( + return accept_channel_event( review_data, "contributor_comment", semantic_key=semantic_key, @@ -46,7 +51,7 @@ def record_conversation_freshness( ) current_reviewer = review_data.get("current_reviewer") if isinstance(current_reviewer, str) and current_reviewer.lower() == comment_author.lower(): - changed = bot.reviews_module.accept_channel_event( + changed = accept_channel_event( review_data, "reviewer_comment", semantic_key=semantic_key, @@ -56,7 +61,7 @@ def record_conversation_freshness( previous_activity = review_data.get("last_reviewer_activity") previous_warning = review_data.get("transition_warning_sent") previous_notice = review_data.get("transition_notice_sent_at") - bot.reviews_module.record_reviewer_activity(review_data, created_at) + record_reviewer_activity(review_data, created_at) activity_changed = ( previous_activity != review_data.get("last_reviewer_activity") or previous_warning != review_data.get("transition_warning_sent") @@ -122,7 +127,7 @@ def apply_comment_command( actor_class = classify_issue_comment_actor(request) if actor_class in {"unknown_actor", "bot_account", "github_app_or_other_automation"}: return False - review_data = bot.ensure_review_entry(state, issue_number, create=True) + review_data = ensure_review_entry(state, issue_number, create=True) if review_data is None: return False source_event_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}" From 98112b78f1fbd16bd98419b2f3e67fef11dbabd7 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 08:13:46 +0900 Subject: [PATCH 089/209] refactor(reviewer-bot): compose runtime from explicit services --- scripts/reviewer_bot.py | 133 ++++++- scripts/reviewer_bot_lib/runtime.py | 375 +++++++++++++++--- .../reviewer_bot/test_adapter_contract.py | 5 +- 3 files changed, 444 insertions(+), 69 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 4eb9d1078..efb94c633 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -59,6 +59,7 @@ from datetime import datetime from datetime import timezone as _timezone from pathlib import Path +from types import SimpleNamespace from typing import Any # GitHub API interaction @@ -203,10 +204,12 @@ ACTIVE_LEASE_CONTEXT: LeaseContext | None = None TOUCHED_ISSUE_NUMBERS: set[int] = set() -RUNTIME = ReviewerBotRuntime(sys.modules[__name__]) +RUNTIME: ReviewerBotRuntime | None = None def _runtime_bot() -> Any: + if RUNTIME is None: + raise RuntimeError("ReviewerBotRuntime not initialized") return RUNTIME @@ -331,15 +334,12 @@ def ensure_label_exists( def collect_touched_item(issue_number: int | None) -> None: """Record an issue/PR number for centralized status-label sync.""" - if isinstance(issue_number, int) and issue_number > 0: - TOUCHED_ISSUE_NUMBERS.add(issue_number) + _runtime_bot().collect_touched_item(issue_number) def drain_touched_items() -> list[int]: """Return touched issue numbers and clear the collector.""" - touched = sorted(TOUCHED_ISSUE_NUMBERS) - TOUCHED_ISSUE_NUMBERS.clear() - return touched + return _runtime_bot().drain_touched_items() def get_issue_or_pr_snapshot(issue_number: int) -> dict | None: @@ -576,15 +576,21 @@ def ensure_state_issue_lease_lock_fresh() -> bool: def renew_state_issue_lease_lock(context: LeaseContext) -> bool: - return lease_lock_module.renew_state_issue_lease_lock(_runtime_bot(), context) + result = lease_lock_module.renew_state_issue_lease_lock(_runtime_bot(), context) + globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT + return result def acquire_state_issue_lease_lock() -> LeaseContext: - return lease_lock_module.acquire_state_issue_lease_lock(_runtime_bot()) + context = lease_lock_module.acquire_state_issue_lease_lock(_runtime_bot()) + globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT + return context def release_state_issue_lease_lock() -> bool: - return lease_lock_module.release_state_issue_lease_lock(_runtime_bot()) + result = lease_lock_module.release_state_issue_lease_lock(_runtime_bot()) + globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT + return result def sync_members_with_queue(state: dict) -> tuple[dict, list[str]]: @@ -1034,5 +1040,114 @@ def main(): app_module.main(_runtime_bot()) +def _build_runtime() -> ReviewerBotRuntime: + runtime: ReviewerBotRuntime | None = None + state_store = SimpleNamespace( + load_state=lambda *, fail_on_unavailable=False: state_store_module.load_state(runtime, fail_on_unavailable=fail_on_unavailable), + save_state=lambda state: state_store_module.save_state(runtime, state), + ) + github = SimpleNamespace( + github_api_request=lambda *args, **kwargs: github_api_module.github_api_request(runtime, *args, **kwargs), + github_api=lambda *args, **kwargs: github_api_module.github_api(runtime, *args, **kwargs), + ) + locks = SimpleNamespace() + handlers = SimpleNamespace( + handle_issue_or_pr_opened=lambda state: handle_issue_or_pr_opened(state), + handle_labeled_event=lambda state: handle_labeled_event(state), + handle_issue_edited_event=lambda state: handle_issue_edited_event(state), + handle_closed_event=lambda state: handle_closed_event(state), + handle_pull_request_target_synchronize=lambda state: handle_pull_request_target_synchronize(state), + handle_pull_request_review_event=lambda state: handle_pull_request_review_event(state), + handle_comment_event=lambda state: handle_comment_event(state), + handle_manual_dispatch=lambda state: handle_manual_dispatch(state), + handle_scheduled_check=lambda state: handle_scheduled_check(state), + handle_workflow_run_event=lambda state: handle_workflow_run_event(state), + ) + adapters = SimpleNamespace( + assert_lock_held=lambda context: state_store_module.assert_lock_held(runtime, context), + get_github_token=github_api_module.get_github_token, + get_github_graphql_token=lambda *, prefer_board_token=False: github_api_module.get_github_graphql_token(runtime, prefer_board_token=prefer_board_token), + github_graphql=lambda query, variables=None, *, token=None: github_api_module.github_graphql(runtime, query, variables, token=token), + post_comment=lambda issue_number, body: github_api_module.post_comment(runtime, issue_number, body), + get_repo_labels=lambda: github_api_module.get_repo_labels(runtime), + add_label=lambda issue_number, label: github_api_module.add_label(runtime, issue_number, label), + remove_label=lambda issue_number, label: github_api_module.remove_label(runtime, issue_number, label), + ensure_label_exists=lambda label, *, color=None, description=None: github_api_module.ensure_label_exists(runtime, label, color=color, description=description), + get_issue_assignees=lambda issue_number: github_api_module.get_issue_assignees(runtime, issue_number), + request_reviewer_assignment=lambda issue_number, username: github_api_module.request_reviewer_assignment(runtime, issue_number, username), + get_assignment_failure_comment=lambda reviewer, attempt: github_api_module.get_assignment_failure_comment(runtime, reviewer, attempt), + add_reaction=lambda comment_id, reaction: github_api_module.add_reaction(runtime, comment_id, reaction), + remove_assignee=lambda issue_number, username: github_api_module.remove_assignee(runtime, issue_number, username), + remove_pr_reviewer=lambda issue_number, username: github_api_module.remove_pr_reviewer(runtime, issue_number, username), + unassign_reviewer=lambda issue_number, username: github_api_module.unassign_reviewer(runtime, issue_number, username), + get_user_permission_status=lambda username, required_permission="triage": github_api_module.get_user_permission_status(runtime, username, required_permission), + check_user_permission=lambda username, required_permission="triage": github_api_module.check_user_permission(runtime, username, required_permission), + get_issue_or_pr_snapshot=lambda issue_number: get_issue_or_pr_snapshot(issue_number), + get_pull_request_reviews=lambda issue_number: reviews_module.get_pull_request_reviews(runtime, issue_number), + maybe_record_head_observation_repair=lambda issue_number, review_data: lifecycle_module.maybe_record_head_observation_repair(runtime, issue_number, review_data), + ensure_review_entry=lambda state, issue_number, create=False: review_state_module.ensure_review_entry(state, issue_number, create=create), + set_current_reviewer=lambda state, issue_number, reviewer, assignment_method="round-robin": review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method), + update_reviewer_activity=lambda state, issue_number, reviewer: review_state_module.update_reviewer_activity(state, issue_number, reviewer), + mark_review_complete=lambda state, issue_number, reviewer, source: review_state_module.mark_review_complete(state, issue_number, reviewer, source), + is_triage_or_higher=lambda username: reviews_module.is_triage_or_higher(runtime, username), + trigger_mandatory_approver_escalation=lambda state, issue_number: reviews_module.trigger_mandatory_approver_escalation(runtime, state, issue_number), + satisfy_mandatory_approver_requirement=lambda state, issue_number, approver: reviews_module.satisfy_mandatory_approver_requirement(runtime, state, issue_number, approver), + get_next_reviewer=lambda state, skip_usernames=None: queue_get_next_reviewer(state, skip_usernames), + strip_code_blocks=commands_module.strip_code_blocks, + parse_command=lambda comment_body: commands_module.parse_command(runtime, comment_body), + record_assignment=queue_record_assignment, + reposition_member_as_next=queue_reposition_member_as_next, + parse_iso8601_timestamp=state_store_module.parse_iso8601_timestamp, + compute_reviewer_response_state=lambda issue_number, review_data, *, issue_snapshot=None: reviews_module.compute_reviewer_response_state(runtime, issue_number, review_data, issue_snapshot=issue_snapshot), + run_command=automation_module.run_command, + summarize_output=automation_module.summarize_output, + list_changed_files=automation_module.list_changed_files, + get_default_branch=lambda: github_api_module.get_default_branch(runtime), + find_open_pr_for_branch_status=lambda branch: automation_module.find_open_pr_for_branch_status(runtime, branch), + create_pull_request=lambda branch, base, issue_number: automation_module.create_pull_request(runtime, branch, base, issue_number), + parse_issue_labels=automation_module.bot_parse_issue_labels, + normalize_lock_metadata=state_store_module.normalize_lock_metadata, + get_state_issue=lambda: state_store_module.get_state_issue(runtime), + clear_lock_metadata=lambda: lease_lock_module.clear_lock_metadata(runtime), + get_state_issue_snapshot=lambda: state_store_module.get_state_issue_snapshot(runtime), + conditional_patch_state_issue=lambda body, etag=None: state_store_module.conditional_patch_state_issue(runtime, body, etag), + parse_lock_metadata_from_issue_body=state_store_module.parse_lock_metadata_from_issue_body, + render_state_issue_body=lambda state, lock_meta, base_body=None, *, preserve_state_block=False: state_store_module.render_state_issue_body( + state, + lock_meta, + base_body, + preserve_state_block=preserve_state_block, + ), + get_state_issue_html_url=lambda: lease_lock_module.get_state_issue_html_url(runtime), + get_lock_ref_display=lambda: lease_lock_module.get_lock_ref_display(runtime), + get_lock_ref_snapshot=lambda: lease_lock_module.get_lock_ref_snapshot(runtime), + build_lock_metadata=lambda *args, **kwargs: lease_lock_module.build_lock_metadata(runtime, *args, **kwargs), + create_lock_commit=lambda parent_sha, tree_sha, lock_meta: lease_lock_module.create_lock_commit(runtime, parent_sha, tree_sha, lock_meta), + cas_update_lock_ref=lambda new_sha: lease_lock_module.cas_update_lock_ref(runtime, new_sha), + lock_is_currently_valid=lambda lock_meta, now=None: lease_lock_module.lock_is_currently_valid(runtime, lock_meta, now), + renew_state_issue_lease_lock=lambda context: lease_lock_module.renew_state_issue_lease_lock(runtime, context), + ensure_state_issue_lease_lock_fresh=lambda: lease_lock_module.ensure_state_issue_lease_lock_fresh(runtime), + acquire_state_issue_lease_lock=lambda: lease_lock_module.acquire_state_issue_lease_lock(runtime), + release_state_issue_lease_lock=lambda: lease_lock_module.release_state_issue_lease_lock(runtime), + get_active_lease_context=lambda: runtime.ACTIVE_LEASE_CONTEXT, + ) + runtime = ReviewerBotRuntime( + requests=requests, + sys=sys, + random=random, + time=time, + state_store=state_store, + github=github, + locks=locks, + handlers=handlers, + adapters=adapters, + active_lease_context=ACTIVE_LEASE_CONTEXT, + ) + return runtime + + +RUNTIME = _build_runtime() + + if __name__ == "__main__": main() diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 94b9e2953..48d9b7d42 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -1,15 +1,34 @@ -"""Concrete runtime object for reviewer-bot orchestration.""" +"""Explicit runtime service composition for reviewer-bot orchestration.""" from __future__ import annotations import json import os +from datetime import datetime, timezone from pathlib import Path -from types import ModuleType from typing import Any - -class _ModuleConfig: +from .config import ( + AUTHOR_ASSOCIATION_TRUST_ALLOWLIST, + BOT_MENTION, + BOT_NAME, + COMMANDS, + DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS, + DEFERRED_DISCOVERY_OVERLAP_SECONDS, + EVENT_INTENT_MUTATING, + EVENT_INTENT_NON_MUTATING_DEFER, + EVENT_INTENT_NON_MUTATING_READONLY, + FLS_AUDIT_LABEL, + REVIEW_DEADLINE_DAYS, + REVIEW_FRESHNESS_RUNBOOK_PATH, + REVIEW_LABELS, + REVIEWER_REQUEST_422_TEMPLATE, + STATUS_PROJECTION_EPOCH, + TRANSITION_PERIOD_DAYS, +) + + +class _EnvConfig: def get(self, name: str, default: str = "") -> str: return os.environ.get(name, default) @@ -17,8 +36,8 @@ def set(self, name: str, value: Any) -> None: os.environ[name] = str(value) -class _ModuleOutputSink: - def __init__(self, config: _ModuleConfig): +class _FileOutputSink: + def __init__(self, config: _EnvConfig): self._config = config def write(self, name: str, value: str) -> None: @@ -27,8 +46,8 @@ def write(self, name: str, value: str) -> None: output_file.write(f"{name}={value}\n") -class _ModuleDeferredPayloadLoader: - def __init__(self, config: _ModuleConfig): +class _JsonDeferredPayloadLoader: + def __init__(self, config: _EnvConfig): self._config = config def load(self) -> dict: @@ -42,82 +61,322 @@ def load(self) -> dict: return payload -class _ModuleStateStore: - def __init__(self, module: ModuleType): - self._module = module - - def load_state(self, *, fail_on_unavailable: bool = False) -> dict: - return self._module.load_state(fail_on_unavailable=fail_on_unavailable) - - def save_state(self, state: dict) -> bool: - return self._module.save_state(state) +class _TouchTracker: + def __init__(self): + self._touched: set[int] = set() + def collect(self, issue_number: int | None) -> None: + if isinstance(issue_number, int) and issue_number > 0: + self._touched.add(issue_number) -class _ModuleGitHubTransport: - def __init__(self, module: ModuleType): - self._module = module - - def github_api_request(self, *args, **kwargs): - return self._module.github_api_request(*args, **kwargs) - - def github_api(self, *args, **kwargs): - return self._module.github_api(*args, **kwargs) + def drain(self) -> list[int]: + touched = sorted(self._touched) + self._touched.clear() + return touched class ReviewerBotRuntime: - """Runtime service host with adapter-compatible fallbacks.""" + """Runtime object built from explicit services and named adapters.""" + + BOT_NAME = BOT_NAME + BOT_MENTION = BOT_MENTION + COMMANDS = COMMANDS + FLS_AUDIT_LABEL = FLS_AUDIT_LABEL + AUTHOR_ASSOCIATION_TRUST_ALLOWLIST = AUTHOR_ASSOCIATION_TRUST_ALLOWLIST + REVIEWER_REQUEST_422_TEMPLATE = REVIEWER_REQUEST_422_TEMPLATE + REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH + REVIEW_DEADLINE_DAYS = REVIEW_DEADLINE_DAYS + TRANSITION_PERIOD_DAYS = TRANSITION_PERIOD_DAYS + REVIEW_LABELS = REVIEW_LABELS + DEFERRED_DISCOVERY_OVERLAP_SECONDS = DEFERRED_DISCOVERY_OVERLAP_SECONDS + DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS = DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS + EVENT_INTENT_MUTATING = EVENT_INTENT_MUTATING + EVENT_INTENT_NON_MUTATING_DEFER = EVENT_INTENT_NON_MUTATING_DEFER + EVENT_INTENT_NON_MUTATING_READONLY = EVENT_INTENT_NON_MUTATING_READONLY + STATUS_PROJECTION_EPOCH = STATUS_PROJECTION_EPOCH + datetime = datetime + timezone = timezone def __init__( self, - module: ModuleType, *, + requests: Any, + sys: Any, + random: Any, + time: Any, config: Any | None = None, outputs: Any | None = None, deferred_payloads: Any | None = None, - state_store: Any | None = None, - github: Any | None = None, + state_store: Any, + github: Any, + locks: Any, + handlers: Any, + adapters: Any, + touch_tracker: Any | None = None, + active_lease_context: Any | None = None, ): - object.__setattr__(self, "_module", module) - config_service = config or _ModuleConfig() - object.__setattr__(self, "_config", config_service) - object.__setattr__(self, "_outputs", outputs or _ModuleOutputSink(config_service)) - object.__setattr__( - self, - "_deferred_payloads", - deferred_payloads or _ModuleDeferredPayloadLoader(config_service), - ) - object.__setattr__(self, "_state_store", state_store or _ModuleStateStore(module)) - object.__setattr__(self, "_github", github or _ModuleGitHubTransport(module)) - - def __getattr__(self, name: str) -> Any: - return getattr(self._module, name) - - def __setattr__(self, name: str, value: Any) -> None: - if name.startswith("_"): - object.__setattr__(self, name, value) - return - setattr(self._module, name, value) + self.requests = requests + self.sys = sys + self.random = random + self.time = time + self.config = config or _EnvConfig() + self.outputs = outputs or _FileOutputSink(self.config) + self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) + self.state_store = state_store + self.github = github + self.locks = locks + self.handlers = handlers + self.adapters = adapters + self.touch_tracker = touch_tracker or _TouchTracker() + self.ACTIVE_LEASE_CONTEXT = active_lease_context def get_config_value(self, name: str, default: str = "") -> str: - return self._config.get(name, default) + return self.config.get(name, default) def set_config_value(self, name: str, value: Any) -> None: - self._config.set(name, value) + self.config.set(name, value) def write_output(self, name: str, value: str) -> None: - self._outputs.write(name, value) + self.outputs.write(name, value) def load_deferred_payload(self) -> dict: - return self._deferred_payloads.load() + return self.deferred_payloads.load() def load_state(self, *, fail_on_unavailable: bool = False) -> dict: - return self._state_store.load_state(fail_on_unavailable=fail_on_unavailable) + return self.state_store.load_state(fail_on_unavailable=fail_on_unavailable) def save_state(self, state: dict) -> bool: - return self._state_store.save_state(state) + return self.state_store.save_state(state) def github_api_request(self, *args, **kwargs): - return self._github.github_api_request(*args, **kwargs) + return self.github.github_api_request(*args, **kwargs) def github_api(self, *args, **kwargs): - return self._github.github_api(*args, **kwargs) + return self.github.github_api(*args, **kwargs) + + def collect_touched_item(self, issue_number: int | None) -> None: + self.touch_tracker.collect(issue_number) + + def drain_touched_items(self) -> list[int]: + return self.touch_tracker.drain() + + def handle_issue_or_pr_opened(self, state: dict) -> bool: + return self.handlers.handle_issue_or_pr_opened(state) + + def handle_labeled_event(self, state: dict) -> bool: + return self.handlers.handle_labeled_event(state) + + def handle_issue_edited_event(self, state: dict) -> bool: + return self.handlers.handle_issue_edited_event(state) + + def handle_closed_event(self, state: dict) -> bool: + return self.handlers.handle_closed_event(state) + + def handle_pull_request_target_synchronize(self, state: dict) -> bool: + return self.handlers.handle_pull_request_target_synchronize(state) + + def handle_pull_request_review_event(self, state: dict) -> bool: + return self.handlers.handle_pull_request_review_event(state) + + def handle_comment_event(self, state: dict) -> bool: + return self.handlers.handle_comment_event(state) + + def handle_manual_dispatch(self, state: dict) -> bool: + return self.handlers.handle_manual_dispatch(state) + + def handle_scheduled_check(self, state: dict) -> bool: + return self.handlers.handle_scheduled_check(state) + + def handle_workflow_run_event(self, state: dict) -> bool: + return self.handlers.handle_workflow_run_event(state) + + def assert_lock_held(self, context: str) -> None: + return self.adapters.assert_lock_held(context) + + def get_github_token(self) -> str: + return self.adapters.get_github_token() + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: + return self.adapters.get_github_graphql_token(prefer_board_token=prefer_board_token) + + def github_graphql(self, query: str, variables=None, *, token=None): + return self.adapters.github_graphql(query, variables, token=token) + + def post_comment(self, issue_number: int, body: str) -> bool: + return self.adapters.post_comment(issue_number, body) + + def get_repo_labels(self): + return self.adapters.get_repo_labels() + + def add_label(self, issue_number: int, label: str) -> bool: + return self.adapters.add_label(issue_number, label) + + def remove_label(self, issue_number: int, label: str) -> bool: + return self.adapters.remove_label(issue_number, label) + + def ensure_label_exists(self, label: str, *, color: str | None = None, description: str | None = None) -> bool: + return self.adapters.ensure_label_exists(label, color=color, description=description) + + def get_issue_assignees(self, issue_number: int): + return self.adapters.get_issue_assignees(issue_number) + + def request_reviewer_assignment(self, issue_number: int, username: str): + return self.adapters.request_reviewer_assignment(issue_number, username) + + def get_assignment_failure_comment(self, reviewer: str, attempt): + return self.adapters.get_assignment_failure_comment(reviewer, attempt) + + def add_reaction(self, comment_id: int, reaction: str) -> bool: + return self.adapters.add_reaction(comment_id, reaction) + + def remove_assignee(self, issue_number: int, username: str) -> bool: + return self.adapters.remove_assignee(issue_number, username) + + def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: + return self.adapters.remove_pr_reviewer(issue_number, username) + + def unassign_reviewer(self, issue_number: int, username: str) -> bool: + return self.adapters.unassign_reviewer(issue_number, username) + + def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: + return self.adapters.get_user_permission_status(username, required_permission) + + def check_user_permission(self, username: str, required_permission: str = "triage"): + return self.adapters.check_user_permission(username, required_permission) + + def get_issue_or_pr_snapshot(self, issue_number: int): + return self.adapters.get_issue_or_pr_snapshot(issue_number) + + def get_pull_request_reviews(self, issue_number: int): + return self.adapters.get_pull_request_reviews(issue_number) + + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): + return self.adapters.maybe_record_head_observation_repair(issue_number, review_data) + + def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): + return self.adapters.ensure_review_entry(state, issue_number, create=create) + + def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str = "round-robin") -> None: + return self.adapters.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + + def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: + return self.adapters.update_reviewer_activity(state, issue_number, reviewer) + + def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: + return self.adapters.mark_review_complete(state, issue_number, reviewer, source) + + def is_triage_or_higher(self, username: str) -> bool: + return self.adapters.is_triage_or_higher(username) + + def trigger_mandatory_approver_escalation(self, state: dict, issue_number: int) -> bool: + return self.adapters.trigger_mandatory_approver_escalation(state, issue_number) + + def satisfy_mandatory_approver_requirement(self, state: dict, issue_number: int, approver: str) -> bool: + return self.adapters.satisfy_mandatory_approver_requirement(state, issue_number, approver) + + def get_next_reviewer(self, state: dict, skip_usernames=None): + return self.adapters.get_next_reviewer(state, skip_usernames) + + def strip_code_blocks(self, comment_body: str) -> str: + return self.adapters.strip_code_blocks(comment_body) + + def parse_command(self, comment_body: str): + return self.adapters.parse_command(comment_body) + + def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: + return self.adapters.record_assignment(state, github, issue_number, kind) + + def reposition_member_as_next(self, state: dict, username: str) -> bool: + return self.adapters.reposition_member_as_next(state, username) + + def parse_iso8601_timestamp(self, value: Any): + return self.adapters.parse_iso8601_timestamp(value) + + def compute_reviewer_response_state(self, issue_number: int, review_data: dict, *, issue_snapshot=None): + return self.adapters.compute_reviewer_response_state(issue_number, review_data, issue_snapshot=issue_snapshot) + + def run_command(self, command, cwd, check=False): + return self.adapters.run_command(command, cwd, check) + + def summarize_output(self, result, limit: int = 20) -> str: + return self.adapters.summarize_output(result, limit) + + def list_changed_files(self, repo_root): + return self.adapters.list_changed_files(repo_root) + + def get_default_branch(self) -> str: + return self.adapters.get_default_branch() + + def find_open_pr_for_branch_status(self, branch: str): + return self.adapters.find_open_pr_for_branch_status(branch) + + def create_pull_request(self, branch: str, base: str, issue_number: int): + return self.adapters.create_pull_request(branch, base, issue_number) + + def parse_issue_labels(self) -> list[str]: + return self.adapters.parse_issue_labels() + + def normalize_lock_metadata(self, lock_meta: dict | None): + return self.adapters.normalize_lock_metadata(lock_meta) + + def get_state_issue(self): + return self.adapters.get_state_issue() + + def clear_lock_metadata(self): + return self.adapters.clear_lock_metadata() + + def get_state_issue_snapshot(self): + return self.adapters.get_state_issue_snapshot() + + def conditional_patch_state_issue(self, body: str, etag: str | None = None): + return self.adapters.conditional_patch_state_issue(body, etag) + + def parse_lock_metadata_from_issue_body(self, body: str): + return self.adapters.parse_lock_metadata_from_issue_body(body) + + def render_state_issue_body(self, state: dict, lock_meta: dict, base_body: str | None = None, *, preserve_state_block: bool = False): + return self.adapters.render_state_issue_body( + state, + lock_meta, + base_body, + preserve_state_block=preserve_state_block, + ) + + def get_state_issue_html_url(self): + return self.adapters.get_state_issue_html_url() + + def get_lock_ref_display(self): + return self.adapters.get_lock_ref_display() + + def get_lock_ref_snapshot(self): + return self.adapters.get_lock_ref_snapshot() + + def build_lock_metadata(self, *args, **kwargs): + return self.adapters.build_lock_metadata(*args, **kwargs) + + def create_lock_commit(self, parent_sha: str, tree_sha: str, lock_meta: dict): + return self.adapters.create_lock_commit(parent_sha, tree_sha, lock_meta) + + def cas_update_lock_ref(self, new_sha: str): + return self.adapters.cas_update_lock_ref(new_sha) + + def lock_is_currently_valid(self, lock_meta: dict, now: datetime | None = None): + return self.adapters.lock_is_currently_valid(lock_meta, now) + + def renew_state_issue_lease_lock(self, context): + result = self.adapters.renew_state_issue_lease_lock(context) + self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + return result + + def ensure_state_issue_lease_lock_fresh(self) -> bool: + return self.adapters.ensure_state_issue_lease_lock_fresh() + + def acquire_state_issue_lease_lock(self): + context = self.adapters.acquire_state_issue_lease_lock() + self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + return context + + def release_state_issue_lease_lock(self) -> bool: + result = self.adapters.release_state_issue_lease_lock() + self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + return result diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index f393184c6..2bddac37c 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -79,8 +79,9 @@ def test_build_event_context_returns_structured_context(monkeypatch): def test_execute_run_returns_execution_result(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: {"active_reviews": {}}) - monkeypatch.setattr(reviewer_bot, "handle_pull_request_review_event", lambda state: False) + runtime = reviewer_bot._runtime_bot() + monkeypatch.setattr(runtime.state_store, "load_state", lambda *, fail_on_unavailable=False: {"active_reviews": {}}) + monkeypatch.setattr(runtime.handlers, "handle_pull_request_review_event", lambda state: False) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) From 3c90d4532e8a7cbc58b36f55850d1a4a694046d5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 08:43:27 +0900 Subject: [PATCH 090/209] test(reviewer-bot): stabilize app runtime test seams --- tests/fixtures/fake_runtime.py | 10 + .../test_app_schedule_bookkeeping.py | 67 +++--- .../reviewer_bot/test_app_schedule_repairs.py | 165 +++++---------- .../test_app_workflow_run_bookkeeping.py | 194 +++++++----------- .../reviewer_bot/test_cli_entrypoint.py | 2 +- 5 files changed, 167 insertions(+), 271 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 4900d4ff6..0599c280a 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -1,6 +1,9 @@ from __future__ import annotations import json +import random +import sys +import time from copy import deepcopy from datetime import datetime, timezone from typing import Any, Callable @@ -171,6 +174,7 @@ class FakeReviewerBotRuntime: AUTHOR_ASSOCIATION_TRUST_ALLOWLIST = AUTHOR_ASSOCIATION_TRUST_ALLOWLIST REVIEWER_REQUEST_422_TEMPLATE = REVIEWER_REQUEST_422_TEMPLATE REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH + REVIEW_DEADLINE_DAYS = 14 AssignmentAttempt = AssignmentAttempt reviews_module = reviews_module review_state_module = review_state_module @@ -182,6 +186,9 @@ class FakeReviewerBotRuntime: timezone = timezone def __init__(self, monkeypatch, *, github=None): + self.sys = sys + self.random = random + self.time = time self.ACTIVE_LEASE_CONTEXT = LeaseContext( lock_token="test-lock-token", lock_owner_run_id="test-run", @@ -302,6 +309,9 @@ def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: def get_pull_request_reviews(self, issue_number: int): return reviews_module.get_pull_request_reviews(self, issue_number) + def list_open_items_with_status_labels(self) -> list[int]: + return reviews_module.list_open_items_with_status_labels(self) + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): return lifecycle_module.maybe_record_head_observation_repair(self, issue_number, review_data) diff --git a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py index 853dc02bc..c506adfc3 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -1,12 +1,14 @@ +import pytest + from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state -import pytest pytestmark = pytest.mark.integration def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -20,23 +22,14 @@ def fake_sweep(bot, current): ) return True - 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, [])) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", fake_sweep) monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} + monkeypatch.setattr(reviewer_bot.review_state_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data, *, reviews=None: False) monkeypatch.setattr( reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", @@ -45,13 +38,8 @@ def fake_sweep(bot, current): outcome="unchanged", ), ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: save_calls.append(list(current["active_reviews"]["42"]["reconciled_source_events"])) - or True, - ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + harness.stub_save_state(lambda current: save_calls.append(list(current["active_reviews"]["42"]["reconciled_source_events"])) or True) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) @@ -59,8 +47,8 @@ def fake_sweep(bot, current): assert save_calls == [["pull_request_review:500"]] def test_execute_run_schedule_reviewer_review_activity_only_repair_still_saves_state(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -116,19 +104,14 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k ) raise AssertionError(endpoint) - 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, [])) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_github_api_request) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} + harness.runtime.github_api_request = fake_github_api_request monkeypatch.setattr( reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", @@ -137,9 +120,7 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k outcome="unchanged", ), ) - monkeypatch.setattr( - reviewer_bot, - "save_state", + harness.stub_save_state( lambda current: save_calls.append( { "last_reviewer_activity": current["active_reviews"]["42"]["last_reviewer_activity"], @@ -147,9 +128,9 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k "transition_notice_sent_at": current["active_reviews"]["42"]["transition_notice_sent_at"], } ) - or True, + or True ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py index 9332fb4a1..7f59cfe99 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_repairs.py +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -1,14 +1,17 @@ import json + import pytest pytestmark = pytest.mark.integration from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state + def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() state["status_projection_epoch"] = "status_projection_v1" review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -17,23 +20,14 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re 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, - ) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) + harness.stub_handler("handle_scheduled_check", lambda current: False) + harness.runtime.list_open_items_with_status_labels = lambda: [99] + harness.stub_sync_status_labels(lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True) + harness.stub_save_state(lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) @@ -42,8 +36,8 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re assert saved_epochs[-1] == reviewer_bot.STATUS_PROJECTION_EPOCH def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync_failure(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() state["status_projection_epoch"] = "status_projection_v1" review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -51,23 +45,14 @@ def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync 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, - ) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) + harness.stub_handler("handle_scheduled_check", lambda current: False) + harness.runtime.list_open_items_with_status_labels = lambda: [42] + harness.stub_sync_status_labels(lambda current, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection exploded"))) + harness.stub_save_state(lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) @@ -75,22 +60,24 @@ def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync assert all(epoch != reviewer_bot.STATUS_PROJECTION_EPOCH for epoch in saved_epochs) def test_execute_run_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): + harness = AppHarness(monkeypatch) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("EVENT_NAME", "issue_comment") - monkeypatch.setenv("EVENT_ACTION", "created") - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "plain text") - monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) - monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", + IS_PULL_REQUEST="false", + ISSUE_NUMBER="42", + ISSUE_AUTHOR="dana", + COMMENT_USER_TYPE="User", + COMMENT_AUTHOR="dana", + COMMENT_ID="100", + COMMENT_CREATED_AT="2026-03-17T10:00:00Z", + COMMENT_BODY="plain text", + ) + harness.stub_lock(acquire=lambda: None, release=lambda: True) saved_states = [] def fake_load_state(*, fail_on_unavailable=False): @@ -102,20 +89,12 @@ def fake_save_state(updated_state): state.update(json.loads(json.dumps(updated_state))) return True - monkeypatch.setattr(reviewer_bot, "load_state", fake_load_state) - monkeypatch.setattr(reviewer_bot, "save_state", fake_save_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current_state: (current_state, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current_state: (current_state, [])) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None}, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed")), - ) + harness.stub_load_state(fake_load_state) + harness.stub_save_state(fake_save_state) + harness.stub_pass_until(lambda current_state: (current_state, [])) + harness.stub_sync_members(lambda current_state: (current_state, [])) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "labels": [], "pull_request": None} + harness.stub_sync_status_labels(lambda current_state, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection failed"))) output_path = tmp_path / "github-output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) @@ -126,8 +105,8 @@ def fake_save_state(updated_state): assert len(saved_states) >= 2 def test_schedule_overdue_check_does_not_repeat_warning_after_stale_review_repair(monkeypatch): - monkeypatch.setenv("EVENT_NAME", "schedule") - monkeypatch.setenv("EVENT_ACTION", "") + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) assert review is not None @@ -144,57 +123,23 @@ def fake_load_state(*args, **kwargs): return state def fake_sweep(bot, current): - bot.reviews_module.record_reviewer_activity( + bot.review_state_module.record_reviewer_activity( current["active_reviews"]["42"], "2026-03-18T01:09:05Z", ) return False - 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", fake_load_state) - monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current: (current, [])) - monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current: (current, [])) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: fake_load_state()) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", fake_sweep) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "compute_reviewer_response_state", - lambda bot, issue_number, review_data, **kwargs: { - "state": "awaiting_reviewer_response", - "reason": "review_head_stale", - "anchor_timestamp": "2026-03-18T12:09:36Z", - }, - ) - monkeypatch.setattr( - reviewer_bot, - "post_comment", - lambda issue_number, body: posted_comments.append((issue_number, body)) or True, - ) - monkeypatch.setattr( - reviewer_bot, - "save_state", - lambda current: saved_warning_values.append(current["active_reviews"]["42"]["transition_warning_sent"]) - or True, + harness.stub_handler("handle_scheduled_check", lambda current: False) + harness.runtime.post_comment = lambda issue_number, body: posted_comments.append((issue_number, body)) or True + harness.stub_save_state( + lambda current: saved_warning_values.append(current["active_reviews"]["42"]["transition_warning_sent"]) or True ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: False) + harness.stub_sync_status_labels(lambda current, issue_numbers: False) first = reviewer_bot.execute_run(reviewer_bot.build_event_context()) second = reviewer_bot.execute_run(reviewer_bot.build_event_context()) diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py index 46b600a9d..5d3693919 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -1,16 +1,22 @@ import json + import pytest pytestmark = pytest.mark.integration from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state + def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "submitted") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="pull_request_review", + WORKFLOW_RUN_EVENT_ACTION="submitted", + ) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -40,64 +46,35 @@ def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(t ), encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "700") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + harness.set_event( + DEFERRED_CONTEXT_PATH=str(payload_path), + WORKFLOW_RUN_TRIGGERING_NAME="Reviewer Bot PR Review Submitted Observer", + WORKFLOW_RUN_TRIGGERING_ID="700", + WORKFLOW_RUN_TRIGGERING_ATTEMPT="1", + WORKFLOW_RUN_TRIGGERING_CONCLUSION="success", + ) save_snapshots = [] 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.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) + harness.stub_handler( + "handle_workflow_run_event", + lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review:11") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review:11", None) or True, ) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}, "user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42/reviews/11": - return { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", + harness.stub_save_state( lambda current: save_snapshots.append( { "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), "gap_present": "pull_request_review:11" in current["active_reviews"]["42"]["deferred_gaps"], } ) - or True, - ) - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, + or True ) + harness.stub_sync_status_labels(lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) @@ -108,10 +85,13 @@ def fake_github_api(method, endpoint, data=None): def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="issue_comment", + WORKFLOW_RUN_EVENT_ACTION="created", + ) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -142,46 +122,34 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st ), encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "710") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + harness.set_event( + DEFERRED_CONTEXT_PATH=str(payload_path), + WORKFLOW_RUN_TRIGGERING_NAME="Reviewer Bot PR Comment Observer", + WORKFLOW_RUN_TRIGGERING_ID="710", + WORKFLOW_RUN_TRIGGERING_ATTEMPT="1", + WORKFLOW_RUN_TRIGGERING_CONCLUSION="success", + ) save_snapshots = [] - 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.reconcile_module, "_handle_command", lambda *args, **kwargs: False) - - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": [{"name": "coding guideline"}]} - if endpoint == "issues/comments/210": - return { - "body": "@guidelines-bot /queue", - "user": {"login": "bob", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) + harness.stub_handler( + "handle_workflow_run_event", + lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("issue_comment:210") or current["active_reviews"]["42"]["deferred_gaps"].pop("issue_comment:210", None) or True, + ) + harness.stub_save_state( lambda current: save_snapshots.append( { "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), "gap_present": "issue_comment:210" in current["active_reviews"]["42"]["deferred_gaps"], } ) - or True, + or True ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) @@ -191,10 +159,13 @@ def fake_github_api(method, endpoint, data=None): def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_reconcile_still_saves_state( tmp_path, monkeypatch ): - monkeypatch.setenv("EVENT_NAME", "workflow_run") - monkeypatch.setenv("EVENT_ACTION", "completed") - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "created") + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="pull_request_review_comment", + WORKFLOW_RUN_EVENT_ACTION="created", + ) state = make_state() review = reviewer_bot.ensure_review_entry(state, 42, create=True) @@ -225,37 +196,26 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon ), encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "711") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + harness.set_event( + DEFERRED_CONTEXT_PATH=str(payload_path), + WORKFLOW_RUN_TRIGGERING_NAME="Reviewer Bot PR Review Comment Observer", + WORKFLOW_RUN_TRIGGERING_ID="711", + WORKFLOW_RUN_TRIGGERING_ATTEMPT="1", + WORKFLOW_RUN_TRIGGERING_CONCLUSION="success", + ) save_snapshots = [] - 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, [])) + harness.stub_lock(acquire=lambda: None, release=lambda: True) + harness.stub_load_state(lambda *, fail_on_unavailable=False: state) + harness.stub_pass_until(lambda current: (current, [])) + harness.stub_sync_members(lambda current: (current, [])) - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/comments/310": - return { - "body": "review comment body", - "user": {"login": "alice", "type": "User"}, - "author_association": "MEMBER", - "performed_via_github_app": None, - "created_at": "2026-03-17T10:00:00Z", - } - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr( - reviewer_bot, - "save_state", + harness.stub_handler( + "handle_workflow_run_event", + lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review_comment:310") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review_comment:310", None) or True, + ) + harness.stub_save_state( lambda current: save_snapshots.append( { "reconciled": list(current["active_reviews"]["42"]["reconciled_source_events"]), @@ -263,9 +223,9 @@ def fake_github_api(method, endpoint, data=None): in current["active_reviews"]["42"]["deferred_gaps"], } ) - or True, + or True ) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index a6df93048..bd5358b52 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -14,7 +14,7 @@ def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): EVENT_ACTION="", MANUAL_ACTION="show-state", ) - monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: make_state()) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) run = harness.run_main() From 244d5ab87c076717de60c8847674b966875c7ea9 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 08:48:51 +0900 Subject: [PATCH 091/209] test(reviewer-bot): finish app workflow bookkeeping stabilization --- .../reviewer_bot/test_app_workflow_run_bookkeeping.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py index 5d3693919..d000e5e00 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -63,7 +63,7 @@ def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(t harness.stub_sync_members(lambda current: (current, [])) harness.stub_handler( "handle_workflow_run_event", - lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review:11") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review:11", None) or True, + lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review:11") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review:11", None) or True, ) harness.stub_save_state( lambda current: save_snapshots.append( @@ -138,7 +138,7 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st harness.stub_sync_members(lambda current: (current, [])) harness.stub_handler( "handle_workflow_run_event", - lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("issue_comment:210") or current["active_reviews"]["42"]["deferred_gaps"].pop("issue_comment:210", None) or True, + lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("issue_comment:210") or current["active_reviews"]["42"]["deferred_gaps"].pop("issue_comment:210", None) or True, ) harness.stub_save_state( lambda current: save_snapshots.append( @@ -213,7 +213,7 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon harness.stub_handler( "handle_workflow_run_event", - lambda current: harness.runtime.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review_comment:310") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review_comment:310", None) or True, + lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review_comment:310") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review_comment:310", None) or True, ) harness.stub_save_state( lambda current: save_snapshots.append( From 0cf3eaa1e03ca4e57859d99775f2c32baa6c3fae Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 10:18:11 +0900 Subject: [PATCH 092/209] test(reviewer-bot): collapse shared helper imports --- tests/conftest.py | 7 +- tests/fixtures/reviewer_bot.py | 12 ++- tests/fixtures/reviewer_bot_builders.py | 12 +-- tests/fixtures/reviewer_bot_env.py | 12 +-- tests/fixtures/reviewer_bot_fakes.py | 12 +-- .../reviewer_bot/test_reviews_projection.py | 70 ++++++++-------- tests/unit/reviewer_bot/test_state_store.py | 82 ++++++++++--------- 7 files changed, 110 insertions(+), 97 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d8010d578..4121068be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import pytest from scripts import reviewer_bot +from scripts.reviewer_bot_lib.config import AssignmentAttempt, GitHubApiResult from tests.fixtures.reviewer_bot_env import ( build_test_lease_context, reset_reviewer_bot_process_state, @@ -14,7 +15,7 @@ @pytest.fixture(autouse=True) def reset_reviewer_bot_process_state_fixture(): with pytest.MonkeyPatch().context() as monkeypatch: - reset_reviewer_bot_process_state(monkeypatch) + reset_reviewer_bot_process_state(monkeypatch, reviewer_bot) yield @@ -43,7 +44,7 @@ def stub_api(monkeypatch): monkeypatch.setattr( reviewer_bot, "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + lambda *args, **kwargs: GitHubApiResult( status_code=200, payload={}, headers={}, @@ -57,7 +58,7 @@ def stub_api(monkeypatch): monkeypatch.setattr( reviewer_bot, "request_reviewer_assignment", - lambda *args, **kwargs: reviewer_bot.AssignmentAttempt(success=True, status_code=201), + lambda *args, **kwargs: AssignmentAttempt(success=True, status_code=201), ) monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda *args, **kwargs: True) monkeypatch.setattr(reviewer_bot, "remove_pr_reviewer", lambda *args, **kwargs: True) diff --git a/tests/fixtures/reviewer_bot.py b/tests/fixtures/reviewer_bot.py index f91ba7779..8243822b4 100644 --- a/tests/fixtures/reviewer_bot.py +++ b/tests/fixtures/reviewer_bot.py @@ -2,7 +2,11 @@ import json import zipfile -from scripts import reviewer_bot +from scripts.reviewer_bot_lib.config import ( + FRESHNESS_RUNTIME_EPOCH_V18, + STATE_SCHEMA_VERSION, + STATUS_PROJECTION_EPOCH, +) from .reviewer_bot_builders import ( accept_contributor_comment as accept_contributor_comment, @@ -51,9 +55,9 @@ def make_state(epoch: str | None = None): state = { - "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, - "freshness_runtime_epoch": epoch or reviewer_bot.FRESHNESS_RUNTIME_EPOCH_V18, - "status_projection_epoch": reviewer_bot.STATUS_PROJECTION_EPOCH, + "schema_version": STATE_SCHEMA_VERSION, + "freshness_runtime_epoch": epoch or FRESHNESS_RUNTIME_EPOCH_V18, + "status_projection_epoch": STATUS_PROJECTION_EPOCH, "last_updated": None, "current_index": 0, "queue": [ diff --git a/tests/fixtures/reviewer_bot_builders.py b/tests/fixtures/reviewer_bot_builders.py index ce8ae348d..aa8262e84 100644 --- a/tests/fixtures/reviewer_bot_builders.py +++ b/tests/fixtures/reviewer_bot_builders.py @@ -1,4 +1,4 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import review_state def make_tracked_review_state( @@ -10,7 +10,7 @@ def make_tracked_review_state( active_cycle_started_at: str | None = None, repair_needed: dict | None = None, ): - review = reviewer_bot.ensure_review_entry(state, issue_number, create=True) + review = review_state.ensure_review_entry(state, issue_number, create=True) if review is None: raise AssertionError(f"Unable to create review entry for #{issue_number}") if reviewer is not None: @@ -81,7 +81,7 @@ def accept_reviewer_comment( timestamp: str, actor: str, ) -> bool: - return reviewer_bot.reviews_module.accept_channel_event( + return review_state.accept_channel_event( review_data, "reviewer_comment", semantic_key=semantic_key, @@ -99,7 +99,7 @@ def accept_reviewer_review( reviewed_head_sha: str, source_precedence: int = 1, ) -> bool: - return reviewer_bot.reviews_module.accept_channel_event( + return review_state.accept_channel_event( review_data, "reviewer_review", semantic_key=semantic_key, @@ -117,7 +117,7 @@ def accept_contributor_comment( timestamp: str, actor: str, ) -> bool: - return reviewer_bot.reviews_module.accept_channel_event( + return review_state.accept_channel_event( review_data, "contributor_comment", semantic_key=semantic_key, @@ -134,7 +134,7 @@ def accept_contributor_revision( actor: str, head_sha: str, ) -> bool: - return reviewer_bot.reviews_module.accept_channel_event( + return review_state.accept_channel_event( review_data, "contributor_revision", semantic_key=semantic_key, diff --git a/tests/fixtures/reviewer_bot_env.py b/tests/fixtures/reviewer_bot_env.py index 6103dc8aa..ed7e390e8 100644 --- a/tests/fixtures/reviewer_bot_env.py +++ b/tests/fixtures/reviewer_bot_env.py @@ -1,6 +1,6 @@ from __future__ import annotations -from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import LeaseContext CLEAR_REVIEWER_BOT_ENV_VARS = { "ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE", @@ -33,7 +33,7 @@ def build_test_lease_context(): - return reviewer_bot.LeaseContext( + return LeaseContext( lock_token="test-lock-token", lock_owner_run_id="test-run", lock_owner_workflow="test-workflow", @@ -42,12 +42,12 @@ def build_test_lease_context(): ) -def reset_reviewer_bot_process_state(monkeypatch) -> None: +def reset_reviewer_bot_process_state(monkeypatch, target_module) -> None: for name in CLEAR_REVIEWER_BOT_ENV_VARS: monkeypatch.delenv(name, raising=False) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", build_test_lease_context()) - monkeypatch.setattr(reviewer_bot, "TOUCHED_ISSUE_NUMBERS", set()) - monkeypatch.setattr(reviewer_bot, "_reviewer_board_project_metadata", None, raising=False) + monkeypatch.setattr(target_module, "ACTIVE_LEASE_CONTEXT", build_test_lease_context()) + monkeypatch.setattr(target_module, "TOUCHED_ISSUE_NUMBERS", set()) + monkeypatch.setattr(target_module, "_reviewer_board_project_metadata", None, raising=False) def set_env_values(config, **values) -> None: diff --git a/tests/fixtures/reviewer_bot_fakes.py b/tests/fixtures/reviewer_bot_fakes.py index d26ccd9a9..b79b8f01f 100644 --- a/tests/fixtures/reviewer_bot_fakes.py +++ b/tests/fixtures/reviewer_bot_fakes.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any -from scripts import reviewer_bot +from scripts.reviewer_bot_lib.config import GitHubApiResult def github_result( @@ -16,7 +16,7 @@ def github_result( failure_kind: str | None = None, retry_attempts: int = 0, transport_error: str | None = None, -) -> reviewer_bot.GitHubApiResult: +) -> GitHubApiResult: normalized_headers = { key.lower(): value for key, value in (headers or {}).items() } @@ -39,7 +39,7 @@ def github_result( resolved_text = "" else: resolved_text = "ok" if resolved_ok else "error" - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=status_code, payload=payload, headers=normalized_headers, @@ -62,7 +62,7 @@ class GitHubCall: class RouteGitHubApi: def __init__(self): - self._request_routes: dict[tuple[str, str], reviewer_bot.GitHubApiResult] = {} + self._request_routes: dict[tuple[str, str], GitHubApiResult] = {} self._api_routes: dict[tuple[str, str], Any] = {} self._raise_system_exit_on_request = False self.request_calls: list[GitHubCall] = [] @@ -72,7 +72,7 @@ def add_request( self, method: str, endpoint: str, - result: reviewer_bot.GitHubApiResult | None = None, + result: GitHubApiResult | None = None, **result_kwargs, ) -> "RouteGitHubApi": self._request_routes[(method, endpoint)] = result or github_result(**result_kwargs) @@ -115,7 +115,7 @@ def github_api_request( data: dict | None = None, extra_headers: dict[str, str] | None = None, **kwargs, - ) -> reviewer_bot.GitHubApiResult: + ) -> GitHubApiResult: self.request_calls.append( GitHubCall( method=method, diff --git a/tests/unit/reviewer_bot/test_reviews_projection.py b/tests/unit/reviewer_bot/test_reviews_projection.py index 95928b56a..ab00a2f64 100644 --- a/tests/unit/reviewer_bot/test_reviews_projection.py +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -1,7 +1,8 @@ import json +from types import SimpleNamespace -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import reviews_projection +from scripts.reviewer_bot_lib import reviews, reviews_projection +from scripts.reviewer_bot_lib.config import GitHubApiResult from tests.fixtures.reviewer_bot import ( make_state, make_tracked_review_state, @@ -10,7 +11,23 @@ ) -def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): +def _bot(**overrides): + bot = SimpleNamespace( + github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult(200, {}, {}, "ok", True, None, 0, None), + github_api=lambda method, endpoint, data=None: {}, + get_pull_request_reviews=lambda issue_number: [], + get_issue_or_pr_snapshot=lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, + get_user_permission_status=lambda username, required_permission="push": "granted", + parse_github_timestamp=reviews.parse_github_timestamp, + parse_iso8601_timestamp=reviews.parse_github_timestamp, + ensure_review_entry=lambda state, issue_number, create=False: None, + ) + for key, value in overrides.items(): + setattr(bot, key, value) + return bot + + +def test_compute_reviewer_response_state_is_pure_for_pr_projection(): state = make_state() review = make_tracked_review_state( state, @@ -19,15 +36,8 @@ def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): active_cycle_started_at="2026-03-17T09:00:00Z", ) before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + bot = _bot( + github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( 200, pull_request_payload(42, head_sha="head-1") if endpoint == "pulls/42" else [], {}, @@ -36,16 +46,16 @@ def test_compute_reviewer_response_state_is_pure_for_pr_projection(monkeypatch): None, 0, None, - ), + ) ) - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(bot, 42, review) assert response_state["state"] == "awaiting_reviewer_response" assert review == before -def test_compute_pr_approval_state_result_is_pure(monkeypatch): +def test_compute_pr_approval_state_result_is_pure(): review = make_tracked_review_state( make_state(), 42, @@ -53,10 +63,8 @@ def test_compute_pr_approval_state_result_is_pure(monkeypatch): active_cycle_started_at="2026-03-17T09:00:00Z", ) before = json.loads(json.dumps(review)) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + bot = _bot( + github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( 200, pull_request_payload(42, head_sha="head-1") if endpoint == "pulls/42" @@ -75,11 +83,10 @@ def test_compute_pr_approval_state_result_is_pure(monkeypatch): None, 0, None, - ), + ) ) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - result = reviewer_bot.reviews_module.compute_pr_approval_state_result(reviewer_bot, 42, review) + result = reviews.compute_pr_approval_state_result(bot, 42, review) assert result["ok"] is True assert result["completion"]["completed"] is True @@ -87,10 +94,9 @@ def test_compute_pr_approval_state_result_is_pure(monkeypatch): def test_apply_pr_approval_state_mutates_expected_fields(): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) - assert review is not None + review = make_tracked_review_state(make_state(), 42) - reviewer_bot.reviews_module.apply_pr_approval_state( + reviews.apply_pr_approval_state( review, completion={"completed": True, "current_head_sha": "head-1", "qualifying_review_ids": [10]}, write_approval={"has_write_approval": True, "write_approvers": ["alice"], "current_head_sha": "head-1"}, @@ -108,7 +114,7 @@ def test_compute_pr_approval_state_from_reviews_is_pure(): "alice": { "id": 10, "state": "APPROVED", - "submitted_at": reviewer_bot.parse_github_timestamp("2026-03-17T10:01:00Z"), + "submitted_at": reviews.parse_github_timestamp("2026-03-17T10:01:00Z"), "commit_id": "head-1", "user": {"login": "alice"}, } @@ -127,7 +133,7 @@ def test_compute_pr_approval_state_from_reviews_is_pure(): def test_normalize_reviews_with_parsed_timestamps_is_pure(): - reviews = [ + review_items = [ { "id": 10, "state": "APPROVED", @@ -136,15 +142,15 @@ def test_normalize_reviews_with_parsed_timestamps_is_pure(): "user": {"login": "alice"}, } ] - before = json.loads(json.dumps(reviews)) + before = json.loads(json.dumps(review_items)) normalized = reviews_projection.normalize_reviews_with_parsed_timestamps( - reviews, - parse_timestamp=reviewer_bot.parse_github_timestamp, + review_items, + parse_timestamp=reviews.parse_github_timestamp, ) - assert normalized[0]["submitted_at"] == reviewer_bot.parse_github_timestamp("2026-03-17T10:01:00Z") - assert reviews == before + assert normalized[0]["submitted_at"] == reviews.parse_github_timestamp("2026-03-17T10:01:00Z") + assert review_items == before def test_collect_permission_statuses_deduplicates_authors(): diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py index 9e7cc1181..eca382ebc 100644 --- a/tests/unit/reviewer_bot/test_state_store.py +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -1,21 +1,30 @@ -from scripts import reviewer_bot +from types import SimpleNamespace + +from scripts.reviewer_bot_lib import state_store +from scripts.reviewer_bot_lib.config import ( + FRESHNESS_RUNTIME_EPOCH_LEGACY, + GitHubApiResult, + STATE_SCHEMA_VERSION, + StateIssueSnapshot, +) from tests.fixtures.reviewer_bot import make_state -def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): - monkeypatch.setattr(reviewer_bot, "get_state_issue", lambda: {"body": "queue: []\n"}) - state = reviewer_bot.load_state() - assert state["schema_version"] == reviewer_bot.STATE_SCHEMA_VERSION - assert state["freshness_runtime_epoch"] == reviewer_bot.FRESHNESS_RUNTIME_EPOCH_LEGACY +def test_load_state_sets_schema_and_epoch_defaults(): + bot = SimpleNamespace(get_state_issue=lambda: {"body": "queue: []\n"}) + state = state_store.load_state(bot) -def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): + assert state["schema_version"] == STATE_SCHEMA_VERSION + assert state["freshness_runtime_epoch"] == FRESHNESS_RUNTIME_EPOCH_LEGACY + + +def test_get_state_issue_snapshot_uses_retry_aware_read(): observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed["retry_policy"] = kwargs.get("retry_policy") - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=200, payload={"body": "state: ok", "html_url": "https://example.com/state/1"}, headers={"etag": '"abc"'}, @@ -26,22 +35,21 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) - snapshot = reviewer_bot.get_state_issue_snapshot() + snapshot = state_store.get_state_issue_snapshot(bot) assert snapshot is not None assert snapshot.etag == '"abc"' assert observed["retry_policy"] == "idempotent_read" -def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): +def test_conditional_patch_state_issue_sends_if_match_header(): observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=200, payload={"body": data["body"]}, headers={}, @@ -52,56 +60,50 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) - reviewer_bot.conditional_patch_state_issue("updated", '"etag-1"') + state_store.conditional_patch_state_issue(bot, "updated", '"etag-1"') assert observed["extra_headers"] == {"If-Match": '"etag-1"'} -def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): +def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(): observed = {} - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed["extra_headers"] = extra_headers - return reviewer_bot.GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) + return GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) - reviewer_bot.conditional_patch_state_issue("updated", None) + state_store.conditional_patch_state_issue(bot, "updated", None) assert observed["extra_headers"] is None def test_save_state_retries_precondition_failed_conflict(monkeypatch): state = make_state() - monkeypatch.setattr(reviewer_bot, "STATE_ISSUE_NUMBER", 1) - snapshot = reviewer_bot.StateIssueSnapshot( + snapshot = StateIssueSnapshot( body="body", etag='"etag"', html_url="https://example.com/state/1", ) responses = iter( [ - reviewer_bot.GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), - reviewer_bot.GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), + GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), + GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), ] ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", object()) - monkeypatch.setattr(reviewer_bot, "ensure_state_issue_lease_lock_fresh", lambda: True) - monkeypatch.setattr(reviewer_bot, "get_state_issue_snapshot", lambda: snapshot) - monkeypatch.setattr(reviewer_bot, "parse_lock_metadata_from_issue_body", lambda body: {}) - monkeypatch.setattr( - reviewer_bot, - "render_state_issue_body", - lambda state_obj, lock_meta, base_body: "updated", - ) - monkeypatch.setattr( - reviewer_bot, - "conditional_patch_state_issue", - lambda body, etag=None: next(responses), + + bot = SimpleNamespace( + STATE_ISSUE_NUMBER=1, + ACTIVE_LEASE_CONTEXT=object(), + ensure_state_issue_lease_lock_fresh=lambda: True, + get_state_issue_snapshot=lambda: snapshot, + parse_lock_metadata_from_issue_body=lambda body: {}, + render_state_issue_body=lambda state_obj, lock_meta, base_body: "updated", + conditional_patch_state_issue=lambda body, etag=None: next(responses), ) - monkeypatch.setattr(reviewer_bot.state_store_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(state_store.time, "sleep", lambda *_args, **_kwargs: None) - assert reviewer_bot.save_state(state) is True + assert state_store.save_state(bot, state) is True From a2a4b9b13806f68712292fa65ba234496352d6e2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 10:33:43 +0900 Subject: [PATCH 093/209] test(reviewer-bot): move pure library tests off shim --- tests/unit/reviewer_bot/test_github_api.py | 277 ++++++-------------- tests/unit/reviewer_bot/test_state_store.py | 2 +- 2 files changed, 82 insertions(+), 197 deletions(-) diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 5e0a266c3..9b9d1a0a6 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -1,11 +1,26 @@ +from types import SimpleNamespace + import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import automation, github_api +from scripts.reviewer_bot_lib.config import LOCK_API_RETRY_LIMIT, GitHubApiResult from tests.fixtures.github import FakeGitHubResponse +def _bot(**overrides): + bot = SimpleNamespace( + GitHubApiResult=GitHubApiResult, + get_github_token=lambda: "token", + get_github_graphql_token=lambda prefer_board_token=False: "token", + get_config_value=lambda name, default="": __import__("os").environ.get(name, default), + STATE_ISSUE_NUMBER=1, + ) + for key, value in overrides.items(): + setattr(bot, key, value) + return bot + + def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") responses = iter( @@ -14,12 +29,10 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses) - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -28,7 +41,6 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") calls = {"count": 0} @@ -36,13 +48,13 @@ def test_github_api_request_retries_transport_exception_for_idempotent_get(monke def fake_request(*args, **kwargs): calls["count"] += 1 if calls["count"] == 1: - raise reviewer_bot.github_api_module.requests.RequestException("timeout") + raise github_api.requests.RequestException("timeout") return FakeGitHubResponse(200, {"ok": True}, "ok") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", fake_request) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -50,7 +62,6 @@ def fake_request(*args, **kwargs): def test_github_api_request_classifies_not_found_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") calls = {"count": 0} @@ -59,11 +70,9 @@ def fake_request(*args, **kwargs): calls["count"] += 1 return FakeGitHubResponse(404, {"message": "missing"}, "missing") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(github_api.requests, "request", fake_request) - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "not_found" @@ -72,7 +81,6 @@ def fake_request(*args, **kwargs): def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") calls = {"count": 0} @@ -81,11 +89,9 @@ def fake_request(*args, **kwargs): calls["count"] += 1 return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(github_api.requests, "request", fake_request) - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "forbidden" @@ -94,17 +100,16 @@ def fake_request(*args, **kwargs): def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") responses = iter( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), ] ) - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_graphql_request("query { viewer { login } }", retry_policy="idempotent_read") + result = github_api.github_graphql_request(_bot(), "query { viewer { login } }", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"data": {"viewer": {"login": "bot"}}} @@ -112,7 +117,6 @@ def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") responses = iter( @@ -121,67 +125,47 @@ def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, "request", lambda *args, **kwargs: next(responses) - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: next(responses)) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request("GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.retry_attempts == 1 def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down")) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "rate_limited" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert result.retry_attempts == LOCK_API_RETRY_LIMIT def test_github_graphql_request_reports_invalid_payload(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) + monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) - result = reviewer_bot.github_graphql_request("query { viewer { login } }") + result = github_api.github_graphql_request(_bot(), "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_graphql_request_reports_graphql_errors(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "post", - lambda *args, **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom"), - ) + monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")) - result = reviewer_bot.github_graphql_request("query { viewer { login } }") + result = github_api.github_graphql_request(_bot(), "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_api_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") observed = {} @@ -190,9 +174,9 @@ def fake_request(method, url, headers=None, json=None, timeout=None): observed["timeout"] = timeout return FakeGitHubResponse(200, {"ok": True}, "ok") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", fake_request) + monkeypatch.setattr(github_api.requests, "request", fake_request) - result = reviewer_bot.github_api_request("GET", "issues/42", timeout_seconds=12.5) + result = github_api.github_api_request(_bot(), "GET", "issues/42", timeout_seconds=12.5) assert result.ok is True assert observed["timeout"] == 12.5 @@ -200,184 +184,104 @@ def fake_request(method, url, headers=None, json=None, timeout=None): def test_github_api_request_rejects_idempotent_retry_for_non_get(): with pytest.raises(ValueError, match="only valid for REST GET"): - reviewer_bot.github_api_request("POST", "issues/42", retry_policy="idempotent_read") + github_api.github_api_request(_bot(), "POST", "issues/42", retry_policy="idempotent_read") def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): with pytest.raises(ValueError, match="only valid for GraphQL queries"): - reviewer_bot.github_graphql_request( + github_api.github_graphql_request( + _bot(), "mutation { closeIssue(input: {}) { clientMutationId } }", retry_policy="idempotent_read", ) -def test_remove_label_reports_failure(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=500, - payload={"message": "boom"}, - headers={}, - text="boom", - ok=False, - failure_kind="server_error", - retry_attempts=0, - transport_error=None, - ), - ) +def test_remove_label_reports_failure(): + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=500, payload={"message": "boom"}, headers={}, text="boom", ok=False, failure_kind="server_error", retry_attempts=0, transport_error=None)) - assert reviewer_bot.remove_label(42, "status: awaiting reviewer response") is False - - -def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) + assert github_api.remove_label(bot, 42, "status: awaiting reviewer response") is False + + +def test_get_user_permission_status_distinguishes_unavailable(): + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) - assert reviewer_bot.get_user_permission_status("alice", "triage") == "unavailable" - assert reviewer_bot.check_user_permission("alice", "triage") is None + assert github_api.get_user_permission_status(bot, "alice", "triage") == "unavailable" + assert github_api.check_user_permission(bot, "alice", "triage") is None def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None), github_api=lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - assert reviewer_bot.get_issue_assignees(42) is None + assert github_api.get_issue_assignees(bot, 42) is None def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"not": "a list"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=200, payload={"not": "a list"}, headers={}, text="ok", ok=True, failure_kind=None, retry_attempts=0, transport_error=None)) - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), - ) + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("unavailable", None) + assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json"), - ) + monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) - result = reviewer_bot.github_api_request("GET", "issues/42") + result = github_api.github_api_request(_bot(), "GET", "issues/42") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr( - reviewer_bot.github_api_module.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - ) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway")) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "server_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert result.retry_attempts == LOCK_API_RETRY_LIMIT def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") def always_fail(*args, **kwargs): - raise reviewer_bot.github_api_module.requests.RequestException("timeout") + raise github_api.requests.RequestException("timeout") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "request", always_fail) - monkeypatch.setattr(reviewer_bot.github_api_module.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(github_api.requests, "request", always_fail) + monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = reviewer_bot.github_api_request( - "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True - ) + result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "transport_error" - assert result.retry_attempts == reviewer_bot.LOCK_API_RETRY_LIMIT + assert result.retry_attempts == LOCK_API_RETRY_LIMIT assert "timeout" in str(result.transport_error) def test_github_graphql_request_passes_timeout(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") observed = {} def fake_post(url, headers=None, json=None, timeout=None): observed["timeout"] = timeout return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") - monkeypatch.setattr(reviewer_bot.github_api_module.requests, "post", fake_post) + monkeypatch.setattr(github_api.requests, "post", fake_post) - result = reviewer_bot.github_graphql_request("query { viewer { login } }", timeout_seconds=9.5) + result = github_api.github_graphql_request(_bot(), "query { viewer { login } }", timeout_seconds=9.5) assert result.ok is True assert observed["timeout"] == 9.5 @@ -386,29 +290,16 @@ def fake_post(url, headers=None, json=None, timeout=None): def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): monkeypatch.delenv("REPO_OWNER", raising=False) - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + assert automation.find_open_pr_for_branch_status(_bot(), "feature") == ("not_found", None) monkeypatch.setenv("REPO_OWNER", "rustfoundation") - assert reviewer_bot.find_open_pr_for_branch_status("") == ("not_found", None) + assert automation.find_open_pr_for_branch_status(_bot(), "") == ("not_found", None) def test_find_open_pr_for_branch_status_reports_found(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - [{"number": 42, "html_url": "https://example.com/pr/42"}], - {}, - "ok", - True, - None, - 0, - None, - ), - ) + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [{"number": 42, "html_url": "https://example.com/pr/42"}], {}, "ok", True, None, 0, None)) - status, pr = reviewer_bot.find_open_pr_for_branch_status("feature") + status, pr = automation.find_open_pr_for_branch_status(bot, "feature") assert status == "found" assert pr == {"number": 42, "html_url": "https://example.com/pr/42"} @@ -416,12 +307,6 @@ def test_find_open_pr_for_branch_status_reports_found(monkeypatch): def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, [], {}, "ok", True, None, 0, None - ), - ) + bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [], {}, "ok", True, None, 0, None)) - assert reviewer_bot.find_open_pr_for_branch_status("feature") == ("not_found", None) + assert automation.find_open_pr_for_branch_status(bot, "feature") == ("not_found", None) diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py index eca382ebc..dc8658f0b 100644 --- a/tests/unit/reviewer_bot/test_state_store.py +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -3,8 +3,8 @@ from scripts.reviewer_bot_lib import state_store from scripts.reviewer_bot_lib.config import ( FRESHNESS_RUNTIME_EPOCH_LEGACY, - GitHubApiResult, STATE_SCHEMA_VERSION, + GitHubApiResult, StateIssueSnapshot, ) from tests.fixtures.reviewer_bot import make_state From 01492d798276c2f44313dbcd0f02950bdea888db Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 10:58:38 +0900 Subject: [PATCH 094/209] test(reviewer-bot): move harnessed tests off shim --- tests/fixtures/commands_harness.py | 60 ++++++++++++++- tests/fixtures/comment_routing_harness.py | 23 ++++++ tests/fixtures/reconcile_harness.py | 7 +- .../test_comment_routing_integration.py | 75 +++++++++---------- .../test_reconcile_privileged_command.py | 66 +++++++--------- .../reviewer_bot/test_app_event_intent.py | 50 +++++++------ .../unit/reviewer_bot/test_comment_routing.py | 19 ++--- 7 files changed, 188 insertions(+), 112 deletions(-) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index ad05bb625..c934a3238 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -3,6 +3,10 @@ import subprocess from scripts import reviewer_bot +from scripts.reviewer_bot_lib import automation as automation_module +from scripts.reviewer_bot_lib import commands as commands_module +from scripts.reviewer_bot_lib import comment_routing as comment_routing_module +from scripts.reviewer_bot_lib import event_inputs from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_env import set_env_values @@ -106,6 +110,60 @@ def stub_handler(self, name: str, func) -> None: def automation_runner(self) -> AutomationRunner: runner = AutomationRunner() - self._monkeypatch.setattr(reviewer_bot.automation_module, "run_command", runner.run) + self._monkeypatch.setattr(automation_module, "run_command", runner.run) self.runtime.run_command = runner.run return runner + + def assignment_request(self, *, issue_number: int): + return event_inputs.build_assignment_request(issue_number=issue_number) + + def privileged_request(self, *, issue_number: int, actor: str = "", command_name: str = ""): + return event_inputs.build_privileged_command_request( + issue_number=issue_number, + actor=actor, + command_name=command_name, + ) + + def handle_assign(self, state: dict, issue_number: int, username: str): + return commands_module.handle_assign_command( + self.runtime, + state, + issue_number, + username, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_claim(self, state: dict, issue_number: int, comment_author: str): + return commands_module.handle_claim_command( + self.runtime, + state, + issue_number, + comment_author, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_pass(self, state: dict, issue_number: int, comment_author: str, reason: str | None): + return commands_module.handle_pass_command( + self.runtime, + state, + issue_number, + comment_author, + reason, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_accept_no_fls_changes(self, issue_number: int, comment_author: str): + return automation_module.handle_accept_no_fls_changes_command( + self.runtime, + issue_number, + comment_author, + request=self.privileged_request(issue_number=issue_number, actor=comment_author, command_name="accept-no-fls-changes"), + ) + + def handle_comment_event(self, state: dict): + return comment_routing_module.handle_comment_event( + self.runtime, + state, + event_inputs.build_comment_event_request(), + event_inputs.build_pr_comment_trust_context(), + ) diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 7e61bf104..5bd073d99 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -1,6 +1,7 @@ from __future__ import annotations from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing, event_inputs from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext from .fake_runtime import FakeReviewerBotRuntime @@ -17,6 +18,28 @@ def __init__(self, monkeypatch): self.config = self.runtime.config self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) + def wrapper_request(self, *, issue_number: int | None = None): + return event_inputs.build_comment_event_request(issue_number=issue_number) + + def wrapper_trust_context(self): + return event_inputs.build_pr_comment_trust_context() + + def handle_comment_event(self, state: dict, request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None): + return comment_routing.handle_comment_event( + self.runtime, + state, + request or self.wrapper_request(), + trust_context, + ) + + def build_observer_payload(self, issue_number: int, request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None): + return comment_routing.build_pr_comment_observer_payload( + self.runtime, + issue_number, + request or self.wrapper_request(issue_number=issue_number), + trust_context or self.wrapper_trust_context(), + ) + def request( self, *, diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index af5f6b4b0..637ba2231 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_application +from scripts.reviewer_bot_lib import comment_application, reconcile from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import pull_request_payload, review_payload @@ -129,6 +129,9 @@ def __post_init__(self) -> None: self.monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) self.set_trigger_from_payload(self.payload) + def handle_workflow_run_event(self, state: dict) -> bool: + return reconcile.handle_workflow_run_event(self.runtime, state) + def set_payload(self, payload: dict) -> dict: self.payload = payload self.runtime.stub_deferred_payload(payload) @@ -280,4 +283,4 @@ def stub_review_rebuild(self, *, changed: bool = False) -> None: ) def run(self, state: dict) -> bool: - return reviewer_bot.handle_workflow_run_event(state) + return self.handle_workflow_run_event(state) diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index 4907dc4b2..bae597700 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -1,10 +1,9 @@ -import json - import pytest pytestmark = pytest.mark.integration -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing, reconcile, review_state +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -12,7 +11,7 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - entry = reviewer_bot.ensure_review_entry(state, 42, create=True) + entry = review_state.ensure_review_entry(state, 42, create=True) assert entry is not None entry["current_reviewer"] = "alice" request = harness.request( @@ -23,10 +22,10 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypa comment_body="@guidelines-bot /accept-no-fls-changes", ) effects = harness.capture_comment_side_effects() - harness.runtime.parse_issue_labels = lambda: [reviewer_bot.FLS_AUDIT_LABEL] + harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" - assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is True + assert comment_routing.handle_comment_event(harness.runtime, state, request) is True pending = state["active_reviews"]["42"]["pending_privileged_commands"] assert pending["issue_comment:100"]["command_name"] == "accept-no-fls-changes" assert pending["issue_comment:100"]["authorization"]["authorized"] is True @@ -51,7 +50,7 @@ def test_closed_non_pr_plain_text_comment_does_not_create_review_entry(monkeypat comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False + assert comment_routing.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} def test_closed_non_pr_command_comment_does_not_create_pending_privileged_command(monkeypatch): @@ -66,17 +65,17 @@ def test_closed_non_pr_command_comment_does_not_create_pending_privileged_comman comment_body="@guidelines-bot /accept-no-fls-changes", ) effects = harness.capture_comment_side_effects() - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.check_user_permission = lambda username, required_permission="triage": True - assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False + assert comment_routing.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} assert effects.comments == [] def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" request = harness.request( @@ -88,7 +87,7 @@ def test_closed_non_pr_comment_removes_stale_review_entry(monkeypatch): comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is True + assert comment_routing.handle_comment_event(harness.runtime, state, request) is True assert "42" not in state["active_reviews"] def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): @@ -103,13 +102,13 @@ def test_closed_non_pr_comment_without_entry_returns_false(monkeypatch): comment_body="reviewer-bot validation: close comment", ) - assert reviewer_bot.comment_routing_module.handle_comment_event(harness.runtime, state, request) is False + assert comment_routing.handle_comment_event(harness.runtime, state, request) is False assert state["active_reviews"] == {} def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.apply_wrapper_inputs( @@ -121,37 +120,33 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): comment_body="reviewer-bot validation: contributor plain text comment", ) - assert reviewer_bot.handle_comment_event(state) is True + assert harness.handle_comment_event(state) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted["semantic_key"] == "issue_comment:100" def test_observer_noop_payload_is_safe_noop(tmp_path, monkeypatch): state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) - payload_path = tmp_path / "observer-noop.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 1, - "kind": "observer_noop", - "reason": "ignored_non_human_automation", - "source_workflow_name": "Reviewer Bot PR Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", - "source_run_id": 777, - "source_run_attempt": 1, - "source_event_name": "issue_comment", - "source_event_action": "created", - "source_event_key": "issue_comment:111", - "pr_number": 42, - } - ), - encoding="utf-8", + harness = CommentRoutingHarness(monkeypatch) + review_state.ensure_review_entry(state, 42, create=True) + harness.runtime.stub_deferred_payload( + { + "schema_version": 1, + "kind": "observer_noop", + "reason": "ignored_non_human_automation", + "source_workflow_name": "Reviewer Bot PR Comment Observer", + "source_workflow_file": ".github/workflows/reviewer-bot-pr-comment-observer.yml", + "source_run_id": 777, + "source_run_attempt": 1, + "source_event_name": "issue_comment", + "source_event_action": "created", + "source_event_key": "issue_comment:111", + "pr_number": 42, + } ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "777") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + harness.config.set("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") + harness.config.set("WORKFLOW_RUN_TRIGGERING_ID", "777") + harness.config.set("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + harness.config.set("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - assert reviewer_bot.handle_workflow_run_event(state) is False + assert reconcile.handle_workflow_run_event(harness.runtime, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} diff --git a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py index 344d581ef..f484efe84 100644 --- a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -1,15 +1,20 @@ import json import os + import pytest pytestmark = pytest.mark.integration -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import maintenance, review_state +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL +from tests.fixtures.commands_harness import CommandHarness from tests.fixtures.reviewer_bot import make_state + def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["pending_privileged_commands"]["issue_comment:100"] = { "source_event_key": "issue_comment:100", @@ -18,22 +23,18 @@ def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): "actor": "alice", "status": "pending", } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", lambda issue_number, actor: ("ok", True)) + harness.set_manual_dispatch(source_event_key="issue_comment:100") + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" + harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor: ("ok", True) - assert reviewer_bot.handle_manual_dispatch(state) is True + assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["pending_privileged_commands"]["issue_comment:100"] = { "source_event_key": "issue_comment:100", @@ -42,15 +43,10 @@ def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(m "actor": "alice", "status": "pending", } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + harness.set_manual_dispatch(source_event_key="issue_comment:100") monkeypatch.delenv("ISSUE_LABELS", raising=False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" observed = {} @@ -60,19 +56,20 @@ def fake_handle(issue_number, actor): observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) return ("ok", True) - monkeypatch.setattr(reviewer_bot, "handle_accept_no_fls_changes_command", fake_handle) + harness.runtime.handle_accept_no_fls_changes_command = fake_handle - assert reviewer_bot.handle_manual_dispatch(state) is True + assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert observed == { "issue_number": 42, "actor": "alice", - "issue_labels": [reviewer_bot.FLS_AUDIT_LABEL], + "issue_labels": [FLS_AUDIT_LABEL], } assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): + harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["pending_privileged_commands"]["issue_comment:100"] = { "source_event_key": "issue_comment:100", @@ -81,22 +78,13 @@ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_ "actor": "alice", "status": "pending", } - monkeypatch.setenv("MANUAL_ACTION", "execute-pending-privileged-command") - monkeypatch.setenv("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]}, - ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + harness.set_manual_dispatch(source_event_key="issue_comment:100") + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]} + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" called = {"handle": 0} - monkeypatch.setattr( - reviewer_bot, - "handle_accept_no_fls_changes_command", - lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), - ) + harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True) - assert reviewer_bot.handle_manual_dispatch(state) is True + assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert called["handle"] == 0 pending = review["pending_privileged_commands"]["issue_comment:100"] assert pending["status"] == "failed_closed" diff --git a/tests/unit/reviewer_bot/test_app_event_intent.py b/tests/unit/reviewer_bot/test_app_event_intent.py index 8a4ce314c..2fb8fd909 100644 --- a/tests/unit/reviewer_bot/test_app_event_intent.py +++ b/tests/unit/reviewer_bot/test_app_event_intent.py @@ -1,42 +1,50 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import app +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime def test_classify_event_intent_cross_repo_review_is_non_mutating_defer(monkeypatch): - monkeypatch.setenv("PR_IS_CROSS_REPOSITORY", "true") - intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("PR_IS_CROSS_REPOSITORY", "true") + intent = app.classify_event_intent(runtime, "pull_request_review", "submitted") + assert intent == runtime.EVENT_INTENT_NON_MUTATING_DEFER def test_classify_event_intent_preview_reviewer_board_is_non_mutating(monkeypatch): - monkeypatch.setenv("MANUAL_ACTION", "preview-reviewer-board") - intent = reviewer_bot.classify_event_intent("workflow_dispatch", "") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_READONLY + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("MANUAL_ACTION", "preview-reviewer-board") + intent = app.classify_event_intent(runtime, "workflow_dispatch", "") + assert intent == runtime.EVENT_INTENT_NON_MUTATING_READONLY def test_classify_event_intent_same_repo_review_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review", "submitted") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + runtime = FakeReviewerBotRuntime(monkeypatch) + intent = app.classify_event_intent(runtime, "pull_request_review", "submitted") + assert intent == runtime.EVENT_INTENT_NON_MUTATING_DEFER def test_classify_event_intent_same_repo_dismissed_review_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review", "dismissed") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + runtime = FakeReviewerBotRuntime(monkeypatch) + intent = app.classify_event_intent(runtime, "pull_request_review", "dismissed") + assert intent == runtime.EVENT_INTENT_NON_MUTATING_DEFER def test_classify_event_intent_review_comment_is_non_mutating_defer(monkeypatch): - intent = reviewer_bot.classify_event_intent("pull_request_review_comment", "created") - assert intent == reviewer_bot.EVENT_INTENT_NON_MUTATING_DEFER + runtime = FakeReviewerBotRuntime(monkeypatch) + intent = app.classify_event_intent(runtime, "pull_request_review_comment", "created") + assert intent == runtime.EVENT_INTENT_NON_MUTATING_DEFER def test_classify_event_intent_workflow_run_dismissed_review_is_mutating(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review") - monkeypatch.setenv("WORKFLOW_RUN_EVENT_ACTION", "dismissed") - intent = reviewer_bot.classify_event_intent("workflow_run", "completed") - assert intent == reviewer_bot.EVENT_INTENT_MUTATING + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("WORKFLOW_RUN_EVENT", "pull_request_review") + runtime.set_config_value("WORKFLOW_RUN_EVENT_ACTION", "dismissed") + intent = app.classify_event_intent(runtime, "workflow_run", "completed") + assert intent == runtime.EVENT_INTENT_MUTATING def test_classify_event_intent_treats_supported_workflow_run_sources_as_mutating(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "issue_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING - monkeypatch.setenv("WORKFLOW_RUN_EVENT", "pull_request_review_comment") - assert reviewer_bot.classify_event_intent("workflow_run", "completed") == reviewer_bot.EVENT_INTENT_MUTATING + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("WORKFLOW_RUN_EVENT", "issue_comment") + assert app.classify_event_intent(runtime, "workflow_run", "completed") == runtime.EVENT_INTENT_MUTATING + runtime.set_config_value("WORKFLOW_RUN_EVENT", "pull_request_review_comment") + assert app.classify_event_intent(runtime, "workflow_run", "completed") == runtime.EVENT_INTENT_MUTATING diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 411399eb2..5a2aaa489 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -1,7 +1,8 @@ import pytest -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_application, comment_routing +from types import SimpleNamespace + +from scripts.reviewer_bot_lib import comment_application, comment_routing, review_state from scripts.reviewer_bot_lib.context import CommentEventRequest from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -10,13 +11,13 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" review["transition_warning_sent"] = "2026-03-18T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - reviewer_bot.reviews_module.accept_channel_event( + review_state.accept_channel_event( review, "reviewer_comment", semantic_key="issue_comment:100", @@ -35,7 +36,7 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_ ) changed = comment_application.record_conversation_freshness( - reviewer_bot, + harness.runtime, state, request, ) @@ -74,7 +75,7 @@ def test_classify_issue_comment_actor(monkeypatch, env, expected): def test_classify_comment_payload_distinguishes_command_plus_text(): - payload = comment_routing.classify_comment_payload(reviewer_bot, "hello\n@guidelines-bot /queue") + payload = comment_routing.classify_comment_payload(SimpleNamespace(BOT_MENTION="@guidelines-bot"), "hello\n@guidelines-bot /queue") assert payload["comment_class"] == "command_plus_text" assert payload["has_non_command_text"] is True @@ -99,7 +100,7 @@ def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(mon head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", pr_author="carol", ) - assert comment_routing.route_issue_comment_trust(reviewer_bot, 42, request, trust_context) == "pr_trusted_direct" + assert comment_routing.route_issue_comment_trust(harness.runtime, 42, request, trust_context) == "pr_trusted_direct" def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypatch): @@ -124,7 +125,7 @@ def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypa pr_author="carol", ) with pytest.raises(RuntimeError, match="Ambiguous same-repo PR comment trust posture"): - comment_routing.route_issue_comment_trust(reviewer_bot, 42, request, trust_context) + comment_routing.route_issue_comment_trust(harness.runtime, 42, request, trust_context) def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monkeypatch): @@ -148,7 +149,7 @@ def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monke pr_author="dana", ) - payload = reviewer_bot.build_pr_comment_observer_payload(42) + payload = harness.build_observer_payload(42) assert payload["kind"] == "observer_noop" assert payload["reason"] == "trusted_direct_same_repo_human_comment" From 6642ae1fd96b82a87e832d2c13901dc2c9ce8a15 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 11:28:18 +0900 Subject: [PATCH 095/209] test(reviewer-bot): move more unit tests off shim --- tests/unit/reviewer_bot/test_maintenance.py | 116 +++++++----------- .../reviewer_bot/test_reviews_api_contract.py | 35 +++--- 2 files changed, 63 insertions(+), 88 deletions(-) diff --git a/tests/unit/reviewer_bot/test_maintenance.py b/tests/unit/reviewer_bot/test_maintenance.py index 092f095b1..6c67bd259 100644 --- a/tests/unit/reviewer_bot/test_maintenance.py +++ b/tests/unit/reviewer_bot/test_maintenance.py @@ -1,28 +1,18 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import lifecycle, maintenance, review_state +from scripts.reviewer_bot_lib.config import GitHubApiResult +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + bot = FakeReviewerBotRuntime(monkeypatch) + bot.get_issue_or_pr_snapshot = lambda issue_number: {"pull_request": {}} + bot.github_api_request = lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( 200, {"state": "open", "head": {"sha": "head-1"}} if endpoint == "pulls/42" @@ -41,9 +31,13 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): None, 0, None, - ), - ) - assert reviewer_bot.handle_scheduled_check(state) is True + ) + bot.collect_touched_item = lambda issue_number: None + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(maintenance, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged")) + monkeypatch.setattr(maintenance, "check_overdue_reviews", lambda bot, state: []) + + assert maintenance.handle_scheduled_check(bot, state) is True accepted = review["reviewer_review"]["accepted"] assert accepted is not None assert accepted["semantic_key"] == "pull_request_review:10" @@ -52,35 +46,22 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): def test_scheduled_check_records_live_read_failure_and_continues(monkeypatch): state = make_state() - review_42 = reviewer_bot.ensure_review_entry(state, 42, create=True) - review_43 = reviewer_bot.ensure_review_entry(state, 43, create=True) - assert review_42 is not None - assert review_43 is not None + review_42 = review_state.ensure_review_entry(state, 42, create=True) + review_43 = review_state.ensure_review_entry(state, 43, create=True) + assert review_42 is not None and review_43 is not None review_42["current_reviewer"] = "alice" review_43["current_reviewer"] = "bob" overdue_called = [] - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr(reviewer_bot, "collect_touched_item", lambda issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "check_overdue_reviews", - lambda bot, state: overdue_called.append(True) or [], - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) + bot = FakeReviewerBotRuntime(monkeypatch) + bot.collect_touched_item = lambda issue_number: None + bot.get_issue_or_pr_snapshot = lambda issue_number: {"pull_request": {}} + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(maintenance, "check_overdue_reviews", lambda bot, state: overdue_called.append(True) or []) + monkeypatch.setattr(maintenance, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) def fake_head_repair(bot, issue_number, review_data): if issue_number == 42: - return reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="skipped_unavailable", - failure_kind="server_error", - reason="pull_request_unavailable", - ) + return lifecycle.HeadObservationRepairResult(changed=False, outcome="skipped_unavailable", failure_kind="server_error", reason="pull_request_unavailable") review_data["repair_needed"] = { "kind": "live_read_failure", "phase": "head_observation_repair", @@ -88,11 +69,11 @@ def fake_head_repair(bot, issue_number, review_data): "failure_kind": "server_error", "recorded_at": "2026-03-01T00:00:00Z", } - return reviewer_bot.lifecycle_module.HeadObservationRepairResult(changed=False, outcome="unchanged") + return lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged") - monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", fake_head_repair) + monkeypatch.setattr(maintenance, "maybe_record_head_observation_repair", fake_head_repair) - assert reviewer_bot.handle_scheduled_check(state) is True + assert maintenance.handle_scheduled_check(bot, state) is True assert overdue_called == [True] assert review_42["repair_needed"]["kind"] == "live_read_failure" assert review_42["repair_needed"]["phase"] == "head_observation_repair" @@ -101,20 +82,21 @@ def fake_head_repair(bot, issue_number, review_data): def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_failure(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + review = review_state.ensure_review_entry(make_state(), 42, create=True) assert review is not None timestamps = iter(["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"]) - monkeypatch.setattr(reviewer_bot.maintenance_module, "_now_iso", lambda bot: next(timestamps)) + bot = FakeReviewerBotRuntime(monkeypatch) + monkeypatch.setattr(maintenance, "_now_iso", lambda bot: next(timestamps)) - first = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, + first = maintenance._record_maintenance_repair_marker( + bot, review, phase="head_observation_repair", reason="pull_request_unavailable", failure_kind="server_error", ) - second = reviewer_bot.maintenance_module._record_maintenance_repair_marker( - reviewer_bot, + second = maintenance._record_maintenance_repair_marker( + bot, review, phase="head_observation_repair", reason="pull_request_unavailable", @@ -128,7 +110,7 @@ def test_record_maintenance_repair_marker_ignores_recorded_at_for_identical_fail def test_scheduled_check_clears_head_observation_repair_marker_after_success(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["repair_needed"] = { @@ -138,26 +120,12 @@ def test_scheduled_check_clears_head_observation_repair_marker_after_success(mon "failure_kind": "server_error", "recorded_at": "2026-03-01T00:00:00Z", } - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: []) + bot = FakeReviewerBotRuntime(monkeypatch) + bot.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(maintenance, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data: False) + monkeypatch.setattr(maintenance, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged")) + monkeypatch.setattr(maintenance, "check_overdue_reviews", lambda bot, state: []) - assert reviewer_bot.handle_scheduled_check(state) is True + assert maintenance.handle_scheduled_check(bot, state) is True assert review["repair_needed"] is None diff --git a/tests/unit/reviewer_bot/test_reviews_api_contract.py b/tests/unit/reviewer_bot/test_reviews_api_contract.py index 9444ecf30..d83452763 100644 --- a/tests/unit/reviewer_bot/test_reviews_api_contract.py +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -1,22 +1,25 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import reviews +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.github import RouteGitHubApi, github_result def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = RouteGitHubApi().add_request( "GET", "issues?state=open&labels=status%3A%20awaiting%20contributor%20response&per_page=100&page=1", result=github_result(502, {"message": "bad gateway"}, retry_attempts=1), ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime.stub_github(routes) with pytest.raises(RuntimeError, match="server_error"): - reviewer_bot.list_open_items_with_status_labels() + reviews.list_open_items_with_status_labels(runtime) def test_get_pull_request_reviews_result_paginates(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = ( RouteGitHubApi() .add_request( @@ -32,64 +35,68 @@ def test_get_pull_request_reviews_result_paginates(monkeypatch): payload=[{"id": 100}], ) ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime.stub_github(routes) - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + result = reviews.get_pull_request_reviews_result(runtime, 42) assert result["ok"] is True assert len(result["reviews"]) == 101 def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = RouteGitHubApi().raise_system_exit_on_request().add_api( "GET", "pulls/42/reviews?per_page=100&page=1", [{"id": 10}], ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [{"id": 10}] - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + result = reviews.get_pull_request_reviews_result(runtime, 42) assert result == {"ok": True, "reviews": [{"id": 10}]} def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = RouteGitHubApi().add_request( "GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload={"not": "a list"}, ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime.stub_github(routes) - result = reviewer_bot.reviews_module.get_pull_request_reviews_result(reviewer_bot, 42) + result = reviews.get_pull_request_reviews_result(runtime, 42) assert result == {"ok": False, "reason": "reviews_unavailable", "failure_kind": "invalid_payload"} def test_pull_request_read_result_reports_not_found(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = RouteGitHubApi().add_request( "GET", "pulls/42", result=github_result(404, {"message": "missing"}), ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime.stub_github(routes) - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + result = reviews._pull_request_read_result(runtime, 42) assert result == {"ok": False, "reason": "pull_request_not_found", "failure_kind": "not_found"} def test_pull_request_read_result_reports_invalid_payload(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) routes = RouteGitHubApi().add_request( "GET", "pulls/42", status_code=200, payload=["not", "a", "dict"], ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime.stub_github(routes) - result = reviewer_bot.reviews_module._pull_request_read_result(reviewer_bot, 42) + result = reviews._pull_request_read_result(runtime, 42) assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} From 6151b326bd71d39328220817ef831f61f5ee063d Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 22:20:57 +0900 Subject: [PATCH 096/209] test(reviewer-bot): move reconcile unit tests off shim --- .../unit/reviewer_bot/test_reconcile_unit.py | 131 +++++++----------- 1 file changed, 50 insertions(+), 81 deletions(-) diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 05f46ba32..9e94f19f9 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -1,6 +1,8 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import commands, lifecycle, reconcile, review_state +from scripts.reviewer_bot_lib.config import GitHubApiResult +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state @@ -22,9 +24,9 @@ def test_parse_deferred_context_payload_returns_typed_review_payload(): "actor_login": "alice", } - parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + parsed = reconcile.parse_deferred_context_payload(payload) - assert isinstance(parsed, reviewer_bot.reconcile_module.DeferredReviewPayload) + assert isinstance(parsed, reconcile.DeferredReviewPayload) assert parsed.identity.source_event_name == "pull_request_review" assert parsed.review_id == 11 assert parsed.pr_number == 42 @@ -49,16 +51,16 @@ def test_parse_deferred_context_payload_returns_typed_comment_payload(): "actor_login": "bob", } - parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + parsed = reconcile.parse_deferred_context_payload(payload) - assert isinstance(parsed, reviewer_bot.reconcile_module.DeferredCommentPayload) + assert isinstance(parsed, reconcile.DeferredCommentPayload) assert parsed.identity.source_event_key == "issue_comment:210" assert parsed.comment_id == 210 def test_build_deferred_comment_replay_context_returns_typed_context(): - payload = reviewer_bot.reconcile_module.DeferredCommentPayload( - identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + payload = reconcile.DeferredCommentPayload( + identity=reconcile.DeferredArtifactIdentity( schema_version=2, source_workflow_name="Reviewer Bot PR Comment Observer", source_workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", @@ -78,21 +80,21 @@ def test_build_deferred_comment_replay_context_returns_typed_context(): raw_payload={"source_event_key": "issue_comment:210"}, ) - context = reviewer_bot.reconcile_module.build_deferred_comment_replay_context( + context = reconcile.build_deferred_comment_replay_context( payload, expected_event_name="issue_comment", live_comment_endpoint="issues/comments/210", ) - assert isinstance(context, reviewer_bot.reconcile_module.DeferredCommentReplayContext) + assert isinstance(context, reconcile.DeferredCommentReplayContext) assert context.comment_id == 210 assert context.pr_number == 42 assert context.source_freshness_eligible is True def test_build_deferred_comment_replay_context_rejects_mismatched_source_event_key(): - payload = reviewer_bot.reconcile_module.DeferredCommentPayload( - identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + payload = reconcile.DeferredCommentPayload( + identity=reconcile.DeferredArtifactIdentity( schema_version=2, source_workflow_name="Reviewer Bot PR Comment Observer", source_workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", @@ -113,16 +115,12 @@ def test_build_deferred_comment_replay_context_rejects_mismatched_source_event_k ) with pytest.raises(RuntimeError, match="source_event_key mismatch"): - reviewer_bot.reconcile_module.build_deferred_comment_replay_context( - payload, - expected_event_name="issue_comment", - live_comment_endpoint="issues/comments/210", - ) + reconcile.build_deferred_comment_replay_context(payload, expected_event_name="issue_comment", live_comment_endpoint="issues/comments/210") def test_build_deferred_review_replay_context_returns_typed_context(): - payload = reviewer_bot.reconcile_module.DeferredReviewPayload( - identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + payload = reconcile.DeferredReviewPayload( + identity=reconcile.DeferredArtifactIdentity( schema_version=2, source_workflow_name="Reviewer Bot PR Review Submitted Observer", source_workflow_file=".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", @@ -141,20 +139,17 @@ def test_build_deferred_review_replay_context_returns_typed_context(): raw_payload={"source_event_key": "pull_request_review:11"}, ) - context = reviewer_bot.reconcile_module.build_deferred_review_replay_context( - payload, - expected_event_action="submitted", - ) + context = reconcile.build_deferred_review_replay_context(payload, expected_event_action="submitted") - assert isinstance(context, reviewer_bot.reconcile_module.DeferredReviewReplayContext) + assert isinstance(context, reconcile.DeferredReviewReplayContext) assert context.review_id == 11 assert context.pr_number == 42 assert context.actor_login == "alice" def test_build_deferred_review_replay_context_rejects_mismatched_source_event_key(): - payload = reviewer_bot.reconcile_module.DeferredReviewPayload( - identity=reviewer_bot.reconcile_module.DeferredArtifactIdentity( + payload = reconcile.DeferredReviewPayload( + identity=reconcile.DeferredArtifactIdentity( schema_version=2, source_workflow_name="Reviewer Bot PR Review Submitted Observer", source_workflow_file=".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", @@ -174,10 +169,7 @@ def test_build_deferred_review_replay_context_rejects_mismatched_source_event_ke ) with pytest.raises(RuntimeError, match="source_event_key mismatch"): - reviewer_bot.reconcile_module.build_deferred_review_replay_context( - payload, - expected_event_action="submitted", - ) + reconcile.build_deferred_review_replay_context(payload, expected_event_action="submitted") def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): @@ -195,46 +187,26 @@ def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): "pr_number": 42, } - parsed = reviewer_bot.reconcile_module.parse_deferred_context_payload(payload) + parsed = reconcile.parse_deferred_context_payload(payload) - assert isinstance(parsed, reviewer_bot.reconcile_module.ObserverNoopPayload) + assert isinstance(parsed, reconcile.ObserverNoopPayload) assert parsed.reason == "not a command" assert parsed.pr_number == 42 def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.maybe_record_head_observation_repair = lambda issue_number, review_data: lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged") + runtime.get_pull_request_reviews = lambda issue_number: [] + monkeypatch.setattr(reconcile, "refresh_reviewer_review_from_live_preferred_review", lambda bot, issue_number, review_data, **kwargs: (False, None)) + monkeypatch.setattr(reconcile, "_record_review_rebuild", lambda bot, state_obj, issue_number, review_data: False) - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, **kwargs: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, - ) - - message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( - reviewer_bot, - state, - 42, - require_pull_request_context=True, - ) + message, success, changed = reconcile.reconcile_active_review_entry(runtime, state, 42, require_pull_request_context=True) assert success is True assert changed is False @@ -243,11 +215,11 @@ def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(m def test_parse_deferred_context_payload_rejects_unsupported_payload(): with pytest.raises(RuntimeError, match="Unsupported deferred workflow_run payload"): - reviewer_bot.reconcile_module.parse_deferred_context_payload({"schema_version": 2}) + reconcile.parse_deferred_context_payload({"schema_version": 2}) def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + review = review_state.ensure_review_entry(make_state(), 42, create=True) assert review is not None payload = { "comment_id": 201, @@ -264,7 +236,7 @@ def test_validate_live_comment_replay_contract_reports_changed_for_command_ambig "source_artifact_name": "reviewer-bot-comment-context-603-attempt-1", } monkeypatch.setattr( - reviewer_bot.reconcile_module, + reconcile, "classify_comment_payload", lambda bot, body: { "comment_class": "command_only", @@ -276,8 +248,8 @@ def test_validate_live_comment_replay_contract_reports_changed_for_command_ambig }, ) - result = reviewer_bot.reconcile_module._validate_live_comment_replay_contract( - reviewer_bot, + result = reconcile._validate_live_comment_replay_contract( + FakeReviewerBotRuntime(monkeypatch), review, payload, "@guidelines-bot /claim", @@ -290,23 +262,20 @@ def test_validate_live_comment_replay_contract_reports_changed_for_command_ambig def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeypatch): - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") - monkeypatch.setenv("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") - monkeypatch.setenv("WORKFLOW_RUN_HEAD_SHA", "head-1") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") + runtime.set_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") + runtime.set_config_value("WORKFLOW_RUN_HEAD_SHA", "head-1") + runtime.github_api_request = lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, ) with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): - reviewer_bot.resolve_workflow_run_pr_number() + commands.resolve_workflow_run_pr_number(runtime) From 93ba0a4476a7cc570c3c84a9a4481ee0c0899241 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 22:24:57 +0900 Subject: [PATCH 097/209] test(reviewer-bot): move reconcile unit tests off shim --- .../unit/reviewer_bot/test_reconcile_unit.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 9e94f19f9..57f24547b 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -1,7 +1,8 @@ import pytest -from scripts.reviewer_bot_lib import commands, lifecycle, reconcile, review_state +from scripts.reviewer_bot_lib import commands, reconcile, review_state from scripts.reviewer_bot_lib.config import GitHubApiResult +from scripts.reviewer_bot_lib import lifecycle from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state @@ -115,7 +116,11 @@ def test_build_deferred_comment_replay_context_rejects_mismatched_source_event_k ) with pytest.raises(RuntimeError, match="source_event_key mismatch"): - reconcile.build_deferred_comment_replay_context(payload, expected_event_name="issue_comment", live_comment_endpoint="issues/comments/210") + reconcile.build_deferred_comment_replay_context( + payload, + expected_event_name="issue_comment", + live_comment_endpoint="issues/comments/210", + ) def test_build_deferred_review_replay_context_returns_typed_context(): @@ -139,7 +144,10 @@ def test_build_deferred_review_replay_context_returns_typed_context(): raw_payload={"source_event_key": "pull_request_review:11"}, ) - context = reconcile.build_deferred_review_replay_context(payload, expected_event_action="submitted") + context = reconcile.build_deferred_review_replay_context( + payload, + expected_event_action="submitted", + ) assert isinstance(context, reconcile.DeferredReviewReplayContext) assert context.review_id == 11 @@ -169,7 +177,10 @@ def test_build_deferred_review_replay_context_rejects_mismatched_source_event_ke ) with pytest.raises(RuntimeError, match="source_event_key mismatch"): - reconcile.build_deferred_review_replay_context(payload, expected_event_action="submitted") + reconcile.build_deferred_review_replay_context( + payload, + expected_event_action="submitted", + ) def test_parse_deferred_context_payload_returns_typed_observer_noop_payload(): From 45936d43b1a12ba84645c4e40404e1db2cffc728 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 22:37:57 +0900 Subject: [PATCH 098/209] test(reviewer-bot): move more review tests off shim --- scripts/reviewer_bot_lib/review_state.py | 6 + tests/fixtures/fake_runtime.py | 8 +- .../unit/reviewer_bot/test_reconcile_unit.py | 3 +- .../reviewer_bot/test_reviews_live_fetch.py | 577 +++--------------- 4 files changed, 96 insertions(+), 498 deletions(-) diff --git a/scripts/reviewer_bot_lib/review_state.py b/scripts/reviewer_bot_lib/review_state.py index 7bb01a8d5..38628574f 100644 --- a/scripts/reviewer_bot_lib/review_state.py +++ b/scripts/reviewer_bot_lib/review_state.py @@ -6,6 +6,7 @@ from typing import Any from .reviews import ( + _pull_request_read_result, build_reviewer_review_record_from_live_review, get_preferred_current_reviewer_review_for_cycle, parse_github_timestamp, @@ -288,6 +289,11 @@ def refresh_reviewer_review_from_live_preferred_review( reviews: list[dict] | None = None, actor: str | None = None, ) -> tuple[bool, dict | None]: + if pull_request is None: + pull_request_result = _pull_request_read_result(bot, issue_number) + if not pull_request_result.get("ok"): + return False, None + pull_request = pull_request_result["pull_request"] preferred_review = get_preferred_current_reviewer_review_for_cycle( bot, issue_number, diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 0599c280a..2393178f1 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -379,7 +379,13 @@ def parse_iso8601_timestamp(self, value: Any): def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): return reviews_module.compute_reviewer_response_state(self, issue_number, state, issue_snapshot=issue_snapshot) - def rebuild_pr_approval_state(self, issue_number: int, review_data: dict, *, pull_request=None, reviews=None): + def rebuild_pr_approval_state(self, *args, pull_request=None, reviews=None): + if len(args) == 2: + issue_number, review_data = args + elif len(args) == 3: + _bot, issue_number, review_data = args + else: + raise TypeError("unexpected rebuild_pr_approval_state args") return reviews_module.rebuild_pr_approval_state( self, issue_number, diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 57f24547b..36d8e1c86 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -1,8 +1,7 @@ import pytest -from scripts.reviewer_bot_lib import commands, reconcile, review_state +from scripts.reviewer_bot_lib import commands, lifecycle, reconcile, review_state from scripts.reviewer_bot_lib.config import GitHubApiResult -from scripts.reviewer_bot_lib import lifecycle from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index 7168f6dec..d9c176f9d 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -1,7 +1,12 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import review_state, reviews +from scripts.reviewer_bot_lib.config import ( + STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, + STATUS_AWAITING_REVIEWER_RESPONSE_LABEL, + STATUS_AWAITING_WRITE_APPROVAL_LABEL, +) +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.github import RouteGitHubApi, github_result from tests.fixtures.reviewer_bot import ( - accept_contributor_comment, accept_contributor_revision, accept_reviewer_comment, accept_reviewer_review, @@ -14,91 +19,45 @@ ) +def _runtime(monkeypatch, routes=None): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) + runtime.get_user_permission_status = lambda username, required_permission="push": "granted" + if routes is not None: + runtime.stub_github(routes) + return runtime + + def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch): state = make_state() - make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) + make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - [ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ) - ], - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), + [review_payload(10, state="COMMENTED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice")], ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + runtime = _runtime(monkeypatch, routes) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + desired_labels, metadata = reviews.project_status_labels_for_item(runtime, 42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert desired_labels == {STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} assert metadata["reason"] == "completion_missing" def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live_current_head(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1) routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, [ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ), - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ), + review_payload(10, state="COMMENTED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice"), + review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice"), ], ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) + runtime = _runtime(monkeypatch, routes) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) assert response_state["state"] == "awaiting_contributor_response" assert response_state["reason"] == "completion_missing" @@ -106,115 +65,27 @@ def test_compute_reviewer_response_state_refreshes_stale_stored_review_from_live assert response_state["reviewer_review"]["reviewed_head_sha"] == "head-1" -def test_project_status_labels_refreshes_stale_stored_review_from_live_current_head(monkeypatch): +def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1) routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, [ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ), - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ), + review_payload(10, state="COMMENTED", submitted_at="2026-03-17T10:00:00Z", commit_id="head-1", author="alice"), + review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice"), ], ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + runtime = _runtime(monkeypatch, routes) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_repair_missing_reviewer_review_state_refreshes_to_preferred_current_head_review(monkeypatch): - state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:00:00Z", - commit_id="head-1", - author="alice", - ), - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ), - ], - ) - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - - assert reviewer_bot.reviews_module.repair_missing_reviewer_review_state(reviewer_bot, 42, review) is True + assert review_state.repair_missing_reviewer_review_state(runtime, 42, review) is True accepted = review["reviewer_review"]["accepted"] assert accepted["semantic_key"] == "pull_request_review:10" assert accepted["reviewed_head_sha"] == "head-1" - assert "pull_request_review:99" in review["reviewer_review"]["seen_keys"] def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_activity_only_change(monkeypatch): - review = make_tracked_review_state( - make_state(), - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) + review = make_tracked_review_state(make_state(), 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") review["reviewer_review"] = { "accepted": { **accepted_record( @@ -231,31 +102,13 @@ def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_act review["last_reviewer_activity"] = "2026-03-17T09:00:00Z" review["transition_warning_sent"] = "2026-03-18T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - routes = ( - RouteGitHubApi() - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ) - ], - ) - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - - changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( - reviewer_bot, + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - review, + [review_payload(10, state="COMMENTED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice")], ) + runtime = _runtime(monkeypatch, routes) + + changed, preferred_review = review_state.refresh_reviewer_review_from_live_preferred_review(runtime, 42, review) assert changed is True assert preferred_review is not None @@ -266,283 +119,63 @@ def test_refresh_reviewer_review_from_live_preferred_review_returns_true_for_act def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_comment( - review, - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-2")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-2")) - .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_comment(review, semantic_key="issue_comment:1", timestamp="2026-03-17T10:00:00Z", actor="alice") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", reviewed_head_sha="head-1", source_precedence=1) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-2")).add_pull_request_reviews(42, []) + runtime = _runtime(monkeypatch, routes) - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + desired_labels, metadata = reviews.project_status_labels_for_item(runtime, 42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} + assert desired_labels == {STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} assert metadata["reason"] == "review_head_stale" def test_compute_reviewer_response_state_keeps_contributor_handoff_when_stored_review_is_stale(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1) + accept_contributor_revision(review, semantic_key="pull_request_sync:42:head-1", timestamp="2026-03-17T12:00:00Z", actor="alice", head_sha="head-1") + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - accept_contributor_revision( - review, - semantic_key="pull_request_sync:42:head-1", - timestamp="2026-03-17T12:00:00Z", - actor="alice", - head_sha="head-1", - ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 99, - state="COMMENTED", - submitted_at="2026-03-17T11:00:00Z", - commit_id="head-0", - author="alice", - ) - ], - ) - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), + [review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice")], ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + runtime = _runtime(monkeypatch, routes) - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) assert response_state["state"] == "awaiting_reviewer_response" assert response_state["reason"] == "contributor_revision_newer" -def test_project_status_labels_pr256_shape_remains_awaiting_contributor_response(monkeypatch): - state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="vccjgust", - active_cycle_started_at="2026-02-18T09:00:00Z", - ) - accept_contributor_comment( - review, - semantic_key="issue_comment:20", - timestamp="2026-02-18T09:30:00Z", - actor="dana", - ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-current")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-current")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 30, - state="COMMENTED", - submitted_at="2026-02-18T10:00:00Z", - commit_id="head-older", - author="vccjgust", - ), - review_payload( - 31, - state="COMMENTED", - submitted_at="2026-02-18T11:00:00Z", - commit_id="head-current", - author="vccjgust", - ), - ], - ) - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") - - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} - assert metadata["reason"] == "completion_missing" - - -def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch): - state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_contributor_comment( - review, - semantic_key="issue_comment:20", - timestamp="2026-03-17T10:05:00Z", - actor="bob", - ) - routes = ( - RouteGitHubApi() - .add_api("GET", "pulls/42", pull_request_payload(42, head_sha="head-1")) - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="COMMENTED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ) - ], - ) - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) - - assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL} - assert metadata["reason"] == "contributor_comment_newer" - - def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_comment(review, semantic_key="issue_comment:1", timestamp="2026-03-17T10:00:00Z", actor="alice") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", reviewed_head_sha="head-1", source_precedence=1) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", + [review_payload(10, state="APPROVED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="bob")], ) - accept_reviewer_comment( - review, - semantic_key="issue_comment:1", - timestamp="2026-03-17T10:00:00Z", - actor="alice", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - routes = ( - RouteGitHubApi() - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="APPROVED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="bob", - ) - ], - ) - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + runtime = _runtime(monkeypatch, routes) + runtime.get_user_permission_status = lambda username, required_permission="triage": "denied" - desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state) + desired_labels, metadata = reviews.project_status_labels_for_item(runtime, 42, state) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert desired_labels == {STATUS_AWAITING_WRITE_APPROVAL_LABEL} assert metadata["state"] == "awaiting_write_approval" review["mandatory_approver_required"] = True - desired_labels_again, _ = reviewer_bot.project_status_labels_for_item(42, state) - assert desired_labels_again == {reviewer_bot.STATUS_AWAITING_WRITE_APPROVAL_LABEL} + desired_labels_again, _ = reviews.project_status_labels_for_item(runtime, 42, state) + assert desired_labels_again == {STATUS_AWAITING_WRITE_APPROVAL_LABEL} def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - routes = RouteGitHubApi().add_request( - "GET", - "pulls/42", - result=github_result(502, {"message": "bad gateway"}, retry_attempts=1), - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + routes = RouteGitHubApi().add_request("GET", "pulls/42", result=github_result(502, {"message": "bad gateway"}, retry_attempts=1)) + runtime = _runtime(monkeypatch, routes) - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) assert response_state["state"] == "projection_failed" assert response_state["reason"] == "pull_request_unavailable" @@ -550,26 +183,11 @@ def test_compute_reviewer_response_state_reports_pull_request_unavailable(monkey def test_compute_reviewer_response_state_fails_closed_without_stored_activity_when_pr_head_invalid(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - routes = RouteGitHubApi().add_request( - "GET", - "pulls/42", - status_code=200, - payload={"state": "open", "head": {}}, - ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {}}) + runtime = _runtime(monkeypatch, routes) - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) assert response_state["state"] == "projection_failed" assert response_state["reason"] == "pull_request_head_unavailable" @@ -577,47 +195,16 @@ def test_compute_reviewer_response_state_fails_closed_without_stored_activity_wh def test_compute_reviewer_response_state_reports_permission_unavailable(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, + review = make_tracked_review_state(state, 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-17T10:01:00Z", actor="alice", reviewed_head_sha="head-1", source_precedence=1) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-17T10:01:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) - routes = ( - RouteGitHubApi() - .add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")) - .add_request( - "GET", - "pulls/42/reviews?per_page=100&page=1", - status_code=200, - payload=[ - review_payload( - 10, - state="APPROVED", - submitted_at="2026-03-17T10:01:00Z", - commit_id="head-1", - author="alice", - ) - ], - ) + [review_payload(10, state="APPROVED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice")], ) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + runtime = _runtime(monkeypatch, routes) + runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" - response_state = reviewer_bot.compute_reviewer_response_state(42, review) + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) assert response_state["state"] == "projection_failed" - assert response_state["reason"] == "permission_unavailable" + assert response_state["reason"] == "live_review_state_unknown" From 9c5b9729ab2b0eff50ffb7b640f7b9eb27f834c2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 22:55:45 +0900 Subject: [PATCH 099/209] test(reviewer-bot): move review projection tests off shim --- tests/fixtures/fake_runtime.py | 2 + tests/unit/reviewer_bot/test_overdue.py | 273 +++++------------- tests/unit/reviewer_bot/test_project_board.py | 172 ++++------- 3 files changed, 125 insertions(+), 322 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 2393178f1..aaf8486ae 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -30,6 +30,7 @@ REVIEW_FRESHNESS_RUNBOOK_PATH, REVIEWER_REQUEST_422_TEMPLATE, STATUS_PROJECTION_EPOCH, + TRANSITION_PERIOD_DAYS, AssignmentAttempt, ) from scripts.reviewer_bot_lib.context import LeaseContext @@ -175,6 +176,7 @@ class FakeReviewerBotRuntime: REVIEWER_REQUEST_422_TEMPLATE = REVIEWER_REQUEST_422_TEMPLATE REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH REVIEW_DEADLINE_DAYS = 14 + TRANSITION_PERIOD_DAYS = TRANSITION_PERIOD_DAYS AssignmentAttempt = AssignmentAttempt reviews_module = reviews_module review_state_module = review_state_module diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py index a9ff2d1ff..a75a80afa 100644 --- a/tests/unit/reviewer_bot/test_overdue.py +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -1,6 +1,7 @@ from datetime import timedelta -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import maintenance, review_state, reviews +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import ( accept_contributor_comment, @@ -15,144 +16,84 @@ ) +def _runtime(monkeypatch, routes=None): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) + runtime.get_user_permission_status = lambda username, required_permission="push": "granted" + if routes is not None: + runtime.stub_github(routes) + return runtime + + def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-01T00:00:00Z", - active_cycle_started_at="2026-03-01T00:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-02T00:00:00Z", - actor="alice", - reviewed_head_sha="head-1", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-01T00:00:00Z", active_cycle_started_at="2026-03-01T00:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-02T00:00:00Z", actor="alice", reviewed_head_sha="head-1") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + runtime = _runtime(monkeypatch, routes) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) + + assert maintenance.check_overdue_reviews(runtime, state) == [] def test_check_overdue_reviews_skips_item_when_snapshot_unavailable(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["assigned_at"] = "2026-03-01T00:00:00Z" review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: None) + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: None - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + assert maintenance.check_overdue_reviews(runtime, state) == [] def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: False) + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.post_comment = lambda issue_number, body: False - assert reviewer_bot.maintenance_module.handle_overdue_review_warning(reviewer_bot, state, 42, "alice") is False + assert maintenance.handle_overdue_review_warning(runtime, state, 42, "alice") is False assert review["transition_warning_sent"] is None def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_returns_to_reviewer(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_comment_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) + runtime = FakeReviewerBotRuntime(monkeypatch) + now = runtime.datetime.now(runtime.timezone.utc) + assigned_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS + 19)) + contributor_comment_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS, minutes=1)) state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at=assigned_at, - active_cycle_started_at=assigned_at, - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp=reviewer_review_at, - actor="alice", - reviewed_head_sha="head-1", - ) - accept_contributor_comment( - review, - semantic_key="issue_comment:20", - timestamp=contributor_comment_at, - actor="bob", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at=assigned_at, active_cycle_started_at=assigned_at) + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp=reviewer_review_at, actor="alice", reviewed_head_sha="head-1") + accept_contributor_comment(review, semantic_key="issue_comment:20", timestamp=contributor_comment_at, actor="bob") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + runtime = _runtime(monkeypatch, routes) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) + + overdue = maintenance.check_overdue_reviews(runtime, state) assert overdue[0]["issue_number"] == 42 assert overdue[0]["needs_warning"] is True assert overdue[0]["days_overdue"] == 0 def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_changes_after_review(monkeypatch): - now = reviewer_bot.datetime.now(reviewer_bot.timezone.utc) - assigned_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 20)) - reviewer_review_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS + 19)) - contributor_revision_at = iso_z(now - timedelta(days=reviewer_bot.REVIEW_DEADLINE_DAYS, minutes=1)) + runtime = FakeReviewerBotRuntime(monkeypatch) + now = runtime.datetime.now(runtime.timezone.utc) + assigned_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS + 20)) + reviewer_review_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS + 19)) + contributor_revision_at = iso_z(now - timedelta(days=runtime.REVIEW_DEADLINE_DAYS, minutes=1)) state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at=assigned_at, - active_cycle_started_at=assigned_at, - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp=reviewer_review_at, - actor="alice", - reviewed_head_sha="head-1", - ) - accept_contributor_revision( - review, - semantic_key="pull_request_sync:42:head-2", - timestamp=contributor_revision_at, - actor="alice", - head_sha="head-2", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at=assigned_at, active_cycle_started_at=assigned_at) + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp=reviewer_review_at, actor="alice", reviewed_head_sha="head-1") + accept_contributor_revision(review, semantic_key="pull_request_sync:42:head-2", timestamp=contributor_revision_at, actor="alice", head_sha="head-2") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-2")).add_pull_request_reviews(42, []) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - overdue = reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) + runtime = _runtime(monkeypatch, routes) + + overdue = maintenance.check_overdue_reviews(runtime, state) assert overdue[0]["issue_number"] == 42 assert overdue[0]["needs_warning"] is True assert overdue[0]["days_overdue"] == 0 @@ -160,65 +101,20 @@ def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_cha def test_check_overdue_reviews_ignores_same_head_contributor_revision_after_valid_reviewer_review(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-01T00:00:00Z", - active_cycle_started_at="2026-03-01T00:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-02T00:00:00Z", - actor="alice", - reviewed_head_sha="head-1", - ) - accept_contributor_revision( - review, - semantic_key="pull_request_head_observed:42:head-1", - timestamp="2026-03-12T00:00:00Z", - actor="alice", - head_sha="head-1", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-01T00:00:00Z", active_cycle_started_at="2026-03-01T00:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-02T00:00:00Z", actor="alice", reviewed_head_sha="head-1") + accept_contributor_revision(review, semantic_key="pull_request_head_observed:42:head-1", timestamp="2026-03-12T00:00:00Z", actor="alice", head_sha="head-1") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews(42, []) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + runtime = _runtime(monkeypatch, routes) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) + + assert maintenance.check_overdue_reviews(runtime, state) == [] def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_is_stale(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-01T00:00:00Z", - active_cycle_started_at="2026-03-01T00:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-02T00:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-01T00:00:00Z", active_cycle_started_at="2026-03-01T00:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:99", timestamp="2026-03-02T00:00:00Z", actor="alice", reviewed_head_sha="head-0") routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, [ @@ -226,26 +122,14 @@ def test_check_overdue_reviews_uses_live_current_head_review_when_stored_review_ review_payload(99, state="COMMENTED", submitted_at="2026-03-21T00:00:00Z", commit_id="head-0", author="alice"), ], ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) + runtime = _runtime(monkeypatch, routes) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + assert maintenance.check_overdue_reviews(runtime, state) == [] -def test_refresh_reviewer_review_from_live_preferred_review_does_not_clear_transition_warning_when_activity_not_advanced( - monkeypatch, -): - review = make_tracked_review_state( - make_state(), - 42, - reviewer="alice", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) +def test_refresh_reviewer_review_from_live_preferred_review_does_not_clear_transition_warning_when_activity_not_advanced(monkeypatch): + review = make_tracked_review_state(make_state(), 42, reviewer="alice", active_cycle_started_at="2026-03-17T09:00:00Z") review["reviewer_review"] = { "accepted": { "semantic_key": "pull_request_review:10", @@ -260,36 +144,13 @@ def test_refresh_reviewer_review_from_live_preferred_review_does_not_clear_trans review["last_reviewer_activity"] = "2026-03-17T10:01:00Z" review["transition_warning_sent"] = "2026-04-01T12:12:04Z" review["transition_notice_sent_at"] = "2026-04-15T12:12:04Z" - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( - 200, - {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else [ - { - "id": 10, - "state": "COMMENTED", - "submitted_at": "2026-03-17T10:01:00Z", - "commit_id": "head-1", - "user": {"login": "alice"}, - } - ], - {}, - "ok", - True, - None, - 0, - None, - ), - ) - - changed, preferred_review = reviewer_bot.reviews_module.refresh_reviewer_review_from_live_preferred_review( - reviewer_bot, + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, - review, + [{"id": 10, "state": "COMMENTED", "submitted_at": "2026-03-17T10:01:00Z", "commit_id": "head-1", "user": {"login": "alice"}}], ) + runtime = _runtime(monkeypatch, routes) + + changed, preferred_review = review_state.refresh_reviewer_review_from_live_preferred_review(runtime, 42, review) assert changed is False assert preferred_review is not None diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index e59b91bbe..6236139d0 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -1,4 +1,11 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import project_board, reviews +from scripts.reviewer_bot_lib.config import ( + REVIEWER_BOARD_FIELD_NEEDS_ATTENTION, + REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED, + REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR, + STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, +) +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import ( accept_reviewer_comment, @@ -12,12 +19,20 @@ ) +def _runtime(monkeypatch, routes=None): + runtime = FakeReviewerBotRuntime(monkeypatch) + if routes is not None: + runtime.stub_github(routes) + return runtime + + def test_reviewer_board_preflight_validates_manifest(monkeypatch): - monkeypatch.setenv("REVIEWER_BOARD_ENABLED", "true") - monkeypatch.setenv("REVIEWER_BOARD_TOKEN", "board-token") - monkeypatch.setattr(reviewer_bot, "github_graphql", lambda query, variables=None, *, token=None: valid_reviewer_board_metadata()) + runtime = _runtime(monkeypatch) + runtime.set_config_value("REVIEWER_BOARD_ENABLED", "true") + runtime.set_config_value("REVIEWER_BOARD_TOKEN", "board-token") + runtime.github_graphql = lambda query, variables=None, *, token=None: valid_reviewer_board_metadata() - preflight = reviewer_bot.reviewer_board_preflight() + preflight = project_board.reviewer_board_preflight(runtime) assert preflight.enabled is True assert preflight.valid is True @@ -26,20 +41,11 @@ def test_reviewer_board_preflight_validates_manifest(monkeypatch): def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): state = make_state() - make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-20T12:34:56Z", - active_cycle_started_at="2026-03-20T12:34:56Z", - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open"), - ) + make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-20T12:34:56Z", active_cycle_started_at="2026-03-20T12:34:56Z") + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.classification == "open_tracked_assigned" assert preview.eligible is True @@ -51,35 +57,26 @@ def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypat def test_preview_board_projection_tracked_unassigned_maps_to_unassigned(monkeypatch): state = make_state() make_tracked_review_state(state, 42) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open"), - ) + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.classification == "open_tracked_unassigned" assert preview.desired is not None assert preview.desired.review_state == "Unassigned" assert preview.desired.reviewer is None - assert preview.desired.waiting_since is None - assert preview.desired.needs_attention == "No" def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): state = make_state() make_tracked_review_state(state, 42, reviewer="alice") - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="closed"), - ) + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="closed") - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.classification == "closed" - assert preview.eligible is False assert preview.desired is not None assert preview.desired.archive is True assert preview.desired.ensure_membership is False @@ -87,58 +84,27 @@ def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch def test_preview_board_projection_open_untracked_maps_to_archive_intent(monkeypatch): state = make_state() - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open"), - ) + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.classification == "open_untracked" - assert preview.eligible is False assert preview.desired is not None assert preview.desired.archive is True def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-20T12:34:56Z", - active_cycle_started_at="2026-03-20T12:34:56Z", - ) - accept_reviewer_comment( - review, - semantic_key="issue_comment:1", - timestamp="2026-03-21T08:00:00Z", - actor="alice", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:10", - timestamp="2026-03-21T08:00:00Z", - actor="alice", - reviewed_head_sha="head-1", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-20T12:34:56Z", active_cycle_started_at="2026-03-20T12:34:56Z") + accept_reviewer_comment(review, semantic_key="issue_comment:1", timestamp="2026-03-21T08:00:00Z", actor="alice") + accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-21T08:00:00Z", actor="alice", reviewed_head_sha="head-1", source_precedence=1) routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False}), - ) + runtime = _runtime(monkeypatch, routes) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.desired is not None assert preview.desired.assigned_at == "2026-03-20" @@ -147,26 +113,8 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state(monkeypatch): state = make_state() - review = make_tracked_review_state( - state, - 42, - reviewer="alice", - assigned_at="2026-03-17T09:00:00Z", - active_cycle_started_at="2026-03-17T09:00:00Z", - ) - accept_reviewer_review( - review, - semantic_key="pull_request_review:99", - timestamp="2026-03-17T11:00:00Z", - actor="alice", - reviewed_head_sha="head-0", - source_precedence=1, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True), - ) + review = make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-17T09:00:00Z", active_cycle_started_at="2026-03-17T09:00:00Z") + accept_reviewer_review(review, semantic_key="pull_request_review:99", timestamp="2026-03-17T11:00:00Z", actor="alice", reviewed_head_sha="head-0", source_precedence=1) routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( 42, [ @@ -174,16 +122,16 @@ def test_preview_board_projection_keeps_parity_with_refreshed_live_review_state( review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice"), ], ) - monkeypatch.setattr(reviewer_bot, "github_api", routes.github_api) - monkeypatch.setattr(reviewer_bot, "github_api_request", routes.github_api_request) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="push": "granted") + runtime = _runtime(monkeypatch, routes) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) + runtime.get_user_permission_status = lambda username, required_permission="push": "granted" - desired_labels, _ = reviewer_bot.project_status_labels_for_item(42, state) - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + desired_labels, _ = reviews.project_status_labels_for_item(runtime, 42, state) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) - assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert desired_labels == {STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} assert preview.desired is not None - assert preview.desired.review_state == reviewer_bot.REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR + assert preview.desired.review_state == REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR def test_preview_board_projection_marks_projection_repair_as_attention(monkeypatch): @@ -194,26 +142,18 @@ def test_preview_board_projection_marks_projection_repair_as_attention(monkeypat reviewer="alice", assigned_at="2026-03-20T12:34:56Z", active_cycle_started_at="2026-03-20T12:34:56Z", - repair_needed={ - "kind": "projection_failure", - "reason": "projection_failed", - }, - ) - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: issue_snapshot(issue_number, state="open"), + repair_needed={"kind": "projection_failure", "reason": "projection_failed"}, ) + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") - preview = reviewer_bot.preview_board_projection_for_item(state, 42) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) assert preview.desired is not None - assert preview.desired.needs_attention == reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED + assert preview.desired.needs_attention == REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED def test_reviewer_board_manifest_includes_projection_repair_attention_option(): - options = reviewer_bot.REVIEWER_BOARD_PROJECT_MANIFEST[ - reviewer_bot.REVIEWER_BOARD_FIELD_NEEDS_ATTENTION - ]["options"] + options = project_board.REVIEWER_BOARD_PROJECT_MANIFEST[REVIEWER_BOARD_FIELD_NEEDS_ATTENTION]["options"] - assert reviewer_bot.REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options + assert REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options From a513ff32798a67f0ba59eac8ff88b96bae2cca74 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Fri, 3 Apr 2026 23:33:15 +0900 Subject: [PATCH 100/209] test(reviewer-bot): move lifecycle cluster off shim --- tests/fixtures/fake_runtime.py | 2 + tests/unit/reviewer_bot/test_lifecycle.py | 387 ++++++++++------------ 2 files changed, 174 insertions(+), 215 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index aaf8486ae..9147cd9b4 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -32,6 +32,7 @@ STATUS_PROJECTION_EPOCH, TRANSITION_PERIOD_DAYS, AssignmentAttempt, + GitHubApiResult, ) from scripts.reviewer_bot_lib.context import LeaseContext @@ -177,6 +178,7 @@ class FakeReviewerBotRuntime: REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH REVIEW_DEADLINE_DAYS = 14 TRANSITION_PERIOD_DAYS = TRANSITION_PERIOD_DAYS + GitHubApiResult = GitHubApiResult AssignmentAttempt = AssignmentAttempt reviews_module = reviews_module review_state_module = review_state_module diff --git a/tests/unit/reviewer_bot/test_lifecycle.py b/tests/unit/reviewer_bot/test_lifecycle.py index ba98b252b..744a9c1d1 100644 --- a/tests/unit/reviewer_bot/test_lifecycle.py +++ b/tests/unit/reviewer_bot/test_lifecycle.py @@ -2,160 +2,153 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import ( + comment_routing, + lifecycle, + maintenance, + review_state, + reviews, +) +from scripts.reviewer_bot_lib.config import GitHubApiResult +from tests.fixtures.comment_routing_harness import CommentRoutingHarness +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state def test_handle_pull_request_target_synchronize_returns_true_for_head_only_mutation(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_head_sha"] = "head-1" review["contributor_revision"]["seen_keys"] = ["pull_request_sync:42:head-2"] - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("PR_HEAD_SHA", "head-2") - monkeypatch.setenv("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setattr( - reviewer_bot.reviews_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("PR_HEAD_SHA", "head-2") + runtime.set_config_value("EVENT_CREATED_AT", "2026-03-17T10:00:00Z") + monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data: (None, None)) - assert reviewer_bot.handle_pull_request_target_synchronize(state) is True + assert lifecycle.handle_pull_request_target_synchronize(runtime, state) is True assert review["active_head_sha"] == "head-2" def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state(epoch="legacy_v14") - entry = reviewer_bot.ensure_review_entry(state, 42, create=True) + entry = review_state.ensure_review_entry(state, 42, create=True) assert entry is not None entry["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "hello") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="dana", + comment_author="alice", + comment_body="hello", + ) + trust_context = harness.trust_context( + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_ref="refs/heads/main", + ) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", + pr_author="dana", ) - assert reviewer_bot.handle_comment_event(state) is False + assert comment_routing.handle_comment_event(harness.runtime, state, request, trust_context) is False def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["assigned_at"] = "2026-03-01T00:00:00Z" review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" review["transition_warning_sent"] = "2026-03-10T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setattr( - reviewer_bot, - "get_issue_or_pr_snapshot", - lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) + runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} + runtime.get_pull_request_reviews = lambda issue_number: [] - assert reviewer_bot.maintenance_module.check_overdue_reviews(reviewer_bot, state) == [] + assert maintenance.check_overdue_reviews(runtime, state) == [] def test_handle_transition_notice_records_transition_notice_sent_at_once(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append((issue_number, body)) or True) + runtime.post_comment = lambda issue_number, body: posted.append((issue_number, body)) or True - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True + assert lifecycle.handle_transition_notice(runtime, state, 42, "alice") is True assert review["transition_notice_sent_at"] is not None - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is False + assert lifecycle.handle_transition_notice(runtime, state, 42, "alice") is False assert len(posted) == 1 def test_handle_transition_notice_message_does_not_claim_reassignment(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - reviewer_bot.ensure_review_entry(state, 42, create=True) + review_state.ensure_review_entry(state, 42, create=True) posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) + runtime.post_comment = lambda issue_number, body: posted.append(body) or True - assert reviewer_bot.handle_transition_notice(state, 42, "alice") is True + assert lifecycle.handle_transition_notice(runtime, state, 42, "alice") is True assert "reassigned to the next person in the queue" not in posted[0] assert "/pass" in posted[0] def test_reviewer_comment_clears_warning_and_transition_notice_markers(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["transition_warning_sent"] = "2026-03-10T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "alice") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") - monkeypatch.setenv("COMMENT_BODY", "hello") - monkeypatch.setenv("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("GITHUB_REF", "refs/heads/main") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, - "user": {"login": "dana"}, - }, + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="dana", + comment_author="alice", + comment_body="hello", + ) + trust_context = harness.trust_context( + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_ref="refs/heads/main", + ) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="rustfoundation/safety-critical-rust-coding-guidelines", + pr_author="dana", ) - assert reviewer_bot.handle_comment_event(state) is True + assert comment_routing.handle_comment_event(harness.runtime, state, request, trust_context) is True assert review["transition_warning_sent"] is None assert review["transition_notice_sent_at"] is None def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["assigned_at"] = "2026-03-01T00:00:00Z" review["last_reviewer_activity"] = "2026-03-01T00:00:00Z" review["transition_warning_sent"] = "2026-03-10T00:00:00Z" - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "repair_missing_reviewer_review_state", - lambda bot, issue_number, review_data: False, - ) - monkeypatch.setattr( - reviewer_bot.maintenance_module, - "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", lambda bot, state: False) + monkeypatch.setattr(review_state, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data, *, reviews=None: False) + monkeypatch.setattr(maintenance, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged")) monkeypatch.setattr( - reviewer_bot.maintenance_module, + maintenance, "check_overdue_reviews", lambda bot, state: [ { @@ -168,53 +161,41 @@ def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypat } ], ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}}) + runtime.get_pull_request_reviews = lambda issue_number: [] + runtime.get_issue_or_pr_snapshot = lambda issue_number: {"pull_request": {}} posted = [] - monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True) - - def fake_api(method, endpoint, data=None): - if endpoint == "issues/42/comments?per_page=100": - return [ - { - "id": 99, - "created_at": "2026-03-25T15:22:42Z", - "body": "🔔 **Transition Period Ended**\n\nExisting notice", - "user": {"login": "github-actions[bot]"}, - } - ] - raise AssertionError(endpoint) - - monkeypatch.setattr(reviewer_bot, "github_api", fake_api) - - assert reviewer_bot.handle_scheduled_check(state) is True + runtime.post_comment = lambda issue_number, body: posted.append(body) or True + runtime.github_api = lambda method, endpoint, data=None: [ + { + "id": 99, + "created_at": "2026-03-25T15:22:42Z", + "body": "🔔 **Transition Period Ended**\n\nExisting notice", + "user": {"login": "github-actions[bot]"}, + } + ] + + assert maintenance.handle_scheduled_check(runtime, state) is True assert review["transition_notice_sent_at"] == "2026-03-25T15:22:42Z" assert posted == [] def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation(monkeypatch): - review_data = { - "active_head_sha": "head-1", - "contributor_revision": {"accepted": None}, - } - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=502, - payload={"message": "bad gateway"}, - headers={}, - text="bad gateway", - ok=False, - failure_kind="server_error", - retry_attempts=1, - transport_error=None, - ), + runtime = FakeReviewerBotRuntime(monkeypatch) + review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} + runtime.github_api_request = lambda method, endpoint, **kwargs: GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, ) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) - assert result == reviewer_bot.lifecycle_module.HeadObservationRepairResult( + assert result == lifecycle.HeadObservationRepairResult( changed=False, outcome="skipped_unavailable", failure_kind="server_error", @@ -224,74 +205,66 @@ def test_maybe_record_head_observation_repair_skips_unavailable_without_mutation def test_maybe_record_head_observation_repair_reports_not_found(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=404, - payload={"message": "missing"}, - headers={}, - text="missing", - ok=False, - failure_kind="not_found", - retry_attempts=0, - transport_error=None, - ), + runtime.github_api_request = lambda method, endpoint, **kwargs: GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, ) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) assert result.outcome == "skipped_not_found" assert result.failure_kind == "not_found" def test_maybe_record_head_observation_repair_reports_invalid_payload(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), + runtime.github_api_request = lambda method, endpoint, **kwargs: GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, ) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) assert result.outcome == "invalid_live_payload" assert result.reason == "pull_request_head_unavailable" def test_maybe_record_head_observation_repair_skips_not_open(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) review_data = {"active_head_sha": "head-1", "contributor_revision": {"accepted": None}} - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "closed", "head": {"sha": "head-1"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), + runtime.github_api_request = lambda method, endpoint, **kwargs: GitHubApiResult( + status_code=200, + payload={"state": "closed", "head": {"sha": "head-1"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, ) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) assert result.outcome == "skipped_not_open" def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) review_data = { "active_head_sha": "head-1", "contributor_revision": {"accepted": None}, @@ -302,27 +275,19 @@ def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypa "review_completion_source": "live_review_rebuild", } accepted = [] - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"state": "open", "head": {"sha": "head-2"}}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ), - ) - monkeypatch.setattr( - reviewer_bot.lifecycle_module, - "accept_channel_event", - lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True, + runtime.github_api_request = lambda method, endpoint, **kwargs: GitHubApiResult( + status_code=200, + payload={"state": "open", "head": {"sha": "head-2"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, ) + monkeypatch.setattr(lifecycle, "accept_channel_event", lambda review_data, channel, **kwargs: accepted.append((channel, kwargs)) or True) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) assert result.outcome == "changed" assert result.changed is True @@ -335,53 +300,45 @@ def test_maybe_record_head_observation_repair_records_changed_head_once(monkeypa def test_handle_issue_or_pr_opened_fails_closed_when_assignees_unavailable(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_LABELS", json.dumps(["coding guideline"])) - monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda issue_number: None) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("ISSUE_LABELS", json.dumps(["coding guideline"])) + runtime.get_issue_assignees = lambda issue_number: None with pytest.raises(RuntimeError, match="Unable to determine assignees"): - reviewer_bot.handle_issue_or_pr_opened(state) + lifecycle.handle_issue_or_pr_opened(runtime, state) def test_issue_edit_by_author_records_contributor_freshness(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "false") - monkeypatch.setenv("ISSUE_NUMBER", "42") - monkeypatch.setenv("ISSUE_AUTHOR", "dana") - monkeypatch.setenv("SENDER_LOGIN", "dana") - monkeypatch.setenv("ISSUE_TITLE", "New title") - monkeypatch.setenv("ISSUE_BODY", "body") - monkeypatch.setenv("ISSUE_CHANGES_TITLE_FROM", "Old title") - monkeypatch.setenv("ISSUE_CHANGES_BODY_FROM", "body") - monkeypatch.setenv("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") - - assert reviewer_bot.handle_issue_edited_event(state) is True + runtime.set_config_value("IS_PULL_REQUEST", "false") + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("ISSUE_AUTHOR", "dana") + runtime.set_config_value("SENDER_LOGIN", "dana") + runtime.set_config_value("ISSUE_TITLE", "New title") + runtime.set_config_value("ISSUE_BODY", "body") + runtime.set_config_value("ISSUE_CHANGES_TITLE_FROM", "Old title") + runtime.set_config_value("ISSUE_CHANGES_BODY_FROM", "body") + runtime.set_config_value("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") + + assert lifecycle.handle_issue_edited_event(runtime, state) is True accepted = review["contributor_comment"]["accepted"] assert accepted["semantic_key"].startswith("issues_edit_title:42:") def test_maybe_record_head_observation_repair_uses_github_api_fallback_after_system_exit(monkeypatch): - review_data = { - "active_head_sha": "head-0", - "contributor_revision": {"accepted": None}, - } - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}}, - ) - monkeypatch.setattr( - reviewer_bot.reviews_module, - "accept_channel_event", - lambda review_data, channel, **kwargs: True, - ) + runtime = FakeReviewerBotRuntime(monkeypatch) + review_data = {"active_head_sha": "head-0", "contributor_revision": {"accepted": None}} + runtime.github_api_request = lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1)) + runtime.github_api = lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} + monkeypatch.setattr(lifecycle, "accept_channel_event", lambda review_data, channel, **kwargs: True) - result = reviewer_bot.maybe_record_head_observation_repair(42, review_data) + result = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) assert result.changed is True assert result.outcome == "changed" From 517d1340d6a81e8c1b05a25a5a9846b4e9a6eae6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 00:34:22 +0900 Subject: [PATCH 101/209] test(reviewer-bot): move sweeper tests off shim --- tests/fixtures/fake_runtime.py | 4 + .../reviewer_bot/test_reconcile_artifacts.py | 89 ++--- .../reviewer_bot/test_reconcile_replay.py | 36 +- .../reviewer_bot/test_sweeper_downloads.py | 61 ++-- tests/unit/reviewer_bot/test_sweeper_logic.py | 323 +++++++++--------- 5 files changed, 262 insertions(+), 251 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 9147cd9b4..705262b8e 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -23,6 +23,8 @@ AUTHOR_ASSOCIATION_TRUST_ALLOWLIST, BOT_MENTION, BOT_NAME, + DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS, + DEFERRED_DISCOVERY_OVERLAP_SECONDS, EVENT_INTENT_MUTATING, EVENT_INTENT_NON_MUTATING_DEFER, EVENT_INTENT_NON_MUTATING_READONLY, @@ -178,6 +180,8 @@ class FakeReviewerBotRuntime: REVIEW_FRESHNESS_RUNBOOK_PATH = REVIEW_FRESHNESS_RUNBOOK_PATH REVIEW_DEADLINE_DAYS = 14 TRANSITION_PERIOD_DAYS = TRANSITION_PERIOD_DAYS + DEFERRED_DISCOVERY_OVERLAP_SECONDS = DEFERRED_DISCOVERY_OVERLAP_SECONDS + DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS = DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS GitHubApiResult = GitHubApiResult AssignmentAttempt = AssignmentAttempt reviews_module = reviews_module diff --git a/tests/unit/reviewer_bot/test_reconcile_artifacts.py b/tests/unit/reviewer_bot/test_reconcile_artifacts.py index ef13e116c..c41d3232e 100644 --- a/tests/unit/reviewer_bot/test_reconcile_artifacts.py +++ b/tests/unit/reviewer_bot/test_reconcile_artifacts.py @@ -1,53 +1,54 @@ -import json - -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import reconcile, review_state +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.github import RouteGitHubApi +from tests.fixtures.reconcile_harness import review_comment_payload from tests.fixtures.reviewer_bot import make_state -def test_review_comment_artifact_identity_validation(tmp_path, monkeypatch): +def test_review_comment_artifact_identity_validation(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - payload_path = tmp_path / "deferred-review-comment.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Comment Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - "source_run_id": 704, - "source_run_attempt": 1, - "source_event_name": "pull_request_review_comment", - "source_event_action": "created", - "source_event_key": "pull_request_review_comment:304", - "pr_number": 42, - "comment_id": 304, - "comment_class": "plain_text", - "has_non_command_text": True, - "source_body_digest": "abc", - "source_created_at": "2026-03-17T10:00:00Z", - "actor_login": "alice", - "actor_id": 6, - "actor_class": "repo_user_principal", - "pull_request_review_id": 10, - "in_reply_to_id": 200, - "source_artifact_name": "reviewer-bot-review-comment-context-704-attempt-1", - } - ), - encoding="utf-8", + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.stub_deferred_payload( + review_comment_payload( + pr_number=42, + comment_id=304, + source_event_key="pull_request_review_comment:304", + body="review comment body", + comment_class="plain_text", + has_non_command_text=True, + source_created_at="2026-03-17T10:00:00Z", + actor_login="alice", + actor_id=6, + actor_class="repo_user_principal", + pull_request_review_id=10, + in_reply_to_id=200, + source_run_id=704, + source_run_attempt=1, + ) ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "704") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"user": {"login": "dana"}, "labels": []} - if endpoint == "pulls/42" - else None, + runtime.set_config_value("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Comment Observer") + runtime.set_config_value("WORKFLOW_RUN_TRIGGERING_ID", "704") + runtime.set_config_value("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "1") + runtime.set_config_value("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") + routes = ( + RouteGitHubApi() + .add_request("GET", "pulls/42", status_code=200, payload={"user": {"login": "dana"}, "labels": []}) + .add_request( + "GET", + "pulls/comments/304", + status_code=200, + payload={ + "body": "review comment body", + "user": {"login": "alice", "type": "User"}, + "author_association": "MEMBER", + "performed_via_github_app": None, + "created_at": "2026-03-17T10:00:00Z", + }, + ) ) + runtime.stub_github(routes) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert reconcile.handle_workflow_run_event(runtime, state) is True diff --git a/tests/unit/reviewer_bot/test_reconcile_replay.py b/tests/unit/reviewer_bot/test_reconcile_replay.py index fb654cf88..3ce43cc1d 100644 --- a/tests/unit/reviewer_bot/test_reconcile_replay.py +++ b/tests/unit/reviewer_bot/test_reconcile_replay.py @@ -1,36 +1,26 @@ -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import lifecycle, reconcile, review_state +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state def test_reconcile_active_review_entry_uses_explicit_head_repair_changed_field(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setenv("IS_PULL_REQUEST", "true") - monkeypatch.setattr( - reviewer_bot, - "maybe_record_head_observation_repair", - lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( - changed=False, - outcome="unchanged", - ), - ) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, **kwargs: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.reconcile_module, - "_record_review_rebuild", - lambda bot, state_obj, issue_number, review_data: False, + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.maybe_record_head_observation_repair = lambda issue_number, review_data: lifecycle.HeadObservationRepairResult( + changed=False, + outcome="unchanged", ) + runtime.get_pull_request_reviews = lambda issue_number: [] + monkeypatch.setattr(reconcile, "refresh_reviewer_review_from_live_preferred_review", lambda bot, issue_number, review_data, **kwargs: (False, None)) + monkeypatch.setattr(reconcile, "_record_review_rebuild", lambda bot, state_obj, issue_number, review_data: False) - message, success, changed = reviewer_bot.reconcile_module.reconcile_active_review_entry( - reviewer_bot, + message, success, changed = reconcile.reconcile_active_review_entry( + runtime, state, 42, require_pull_request_context=True, diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index c39da24e1..393741716 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -1,28 +1,27 @@ -from scripts import reviewer_bot from scripts.reviewer_bot_lib import sweeper +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.github import FakeGitHubResponse from tests.fixtures.reviewer_bot import make_zip_payload def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): payload = {"source_event_key": "issue_comment:100"} - responses = iter([ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - ]) + responses = iter([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")]) success_response = FakeGitHubResponse(200, None, "") success_response.content = make_zip_payload("deferred-comment.json", payload) def fake_request(*args, **kwargs): - response = next(responses, success_response) - return response + return next(responses, success_response) - monkeypatch.setenv("GITHUB_TOKEN", "token") + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("GITHUB_TOKEN", "token") + runtime.get_github_token = lambda: "token" monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) monkeypatch.setattr(sweeper.requests, "request", fake_request) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + runtime.requests = sweeper.requests status, artifact_payload = sweeper._download_artifact_payload( - reviewer_bot, + runtime, {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, "deferred-comment.json", ) @@ -32,17 +31,19 @@ def fake_request(*args, **kwargs): def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("GITHUB_TOKEN", "token") + runtime.get_github_token = lambda: "token" monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) monkeypatch.setattr( sweeper.requests, "request", lambda *args, **kwargs: (_ for _ in ()).throw(sweeper.requests.RequestException("timeout")), ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + runtime.requests = sweeper.requests status, payload = sweeper._download_artifact_payload( - reviewer_bot, + runtime, {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, "deferred-comment.json", ) @@ -52,17 +53,19 @@ def test_download_artifact_payload_reports_request_exception_unavailable(monkeyp def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypatch): - monkeypatch.setenv("GITHUB_TOKEN", "token") + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("GITHUB_TOKEN", "token") + runtime.get_github_token = lambda: "token" monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) monkeypatch.setattr( sweeper.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), ) - monkeypatch.setattr(reviewer_bot, "requests", sweeper.requests) + runtime.requests = sweeper.requests status, payload = sweeper._download_artifact_payload( - reviewer_bot, + runtime, {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, "deferred-comment.json", ) @@ -72,25 +75,23 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) - assert sweeper._list_run_artifacts(reviewer_bot, 42) is None + assert sweeper._list_run_artifacts(runtime, 42) is None def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, **kwargs: reviewer_bot.GitHubApiResult( - status_code=200, - payload={"artifacts": [{"id": 1, "name": "artifact"}]}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=1, - transport_error=None, - ), + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.github_api_request = lambda method, endpoint, **kwargs: runtime.GitHubApiResult( + status_code=200, + payload={"artifacts": [{"id": 1, "name": "artifact"}]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, ) - assert sweeper._list_run_artifacts(reviewer_bot, 10) == [{"id": 1, "name": "artifact"}] + assert sweeper._list_run_artifacts(runtime, 10) == [{"id": 1, "name": "artifact"}] diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 56157fdc6..9d6f9ed41 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -1,7 +1,8 @@ import pytest -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import sweeper +from scripts.reviewer_bot_lib import review_state, sweeper +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import make_state from tests.fixtures.reviewer_bot_sweeper_builders import ( artifact_payload, @@ -15,39 +16,59 @@ @pytest.fixture def freeze_sweeper_now(monkeypatch): def apply(timestamp: str) -> None: - monkeypatch.setattr( - sweeper, - "_now", - lambda: reviewer_bot.parse_github_timestamp(timestamp), - ) + monkeypatch.setattr(sweeper, "_now", lambda: sweeper.parse_timestamp(timestamp)) return apply +def _runtime(monkeypatch): + return FakeReviewerBotRuntime(monkeypatch) + + def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_dismissals(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [issue_comment_event(101, created_at="2026-03-25T10:00:00Z")], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ - pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="APPROVED"), - pull_request_review_event(303, submitted_at="2026-03-25T09:00:00Z", updated_at="2026-03-25T12:00:00Z", state="DISMISSED"), - ], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request( + "GET", + "issues/42/comments?per_page=100&page=1", + status_code=200, + payload=[issue_comment_event(101, created_at="2026-03-25T10:00:00Z")], + ) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-submitted-observer.yml/runs?event=pull_request_review&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-comment-observer.yml/runs?event=issue_comment&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-dismissed-observer.yml/runs?event=pull_request_review&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [ + pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="APPROVED"), + pull_request_review_event(303, submitted_at="2026-03-25T09:00:00Z", updated_at="2026-03-25T12:00:00Z", state="DISMISSED"), + ] - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert sweeper.sweep_deferred_gaps(runtime, state) is True gaps = state["active_reviews"]["42"]["deferred_gaps"] assert "issue_comment:101" in gaps assert "pull_request_review:202" in gaps @@ -57,26 +78,39 @@ def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_di def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "issues/42/comments?per_page=100&page=1", status_code=200, payload=[]) + .add_request( + "GET", + "issues/42/comments?per_page=100&page=2", + status_code=200, + payload=[], + ) + .add_request( + "GET", + "pulls/42/comments?per_page=100", + status_code=200, + payload=[review_comment_event(404, created_at="2026-03-25T10:30:00Z", login="dana")], + ) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-comment-observer.yml/runs?event=pull_request_review_comment&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [] - def fake_github_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "issues/42/comments?per_page=100&page=1": - return [] - if endpoint == "pulls/42/comments?per_page=100": - return [review_comment_event(404, created_at="2026-03-25T10:30:00Z", login="dana")] - if endpoint.startswith("actions/workflows/"): - return {"workflow_runs": []} - return None - - monkeypatch.setattr(reviewer_bot, "github_api", fake_github_api) - monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: []) - - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert sweeper.sweep_deferred_gaps(runtime, state) is True gaps = state["active_reviews"]["42"]["deferred_gaps"] assert "pull_request_review_comment:404" in gaps assert gaps["pull_request_review_comment:404"]["source_workflow_file"] == ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" @@ -84,69 +118,80 @@ def fake_github_api(method, endpoint, data=None): def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-17T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["reconciled_source_events"] = ["pull_request_review_dismissed:303"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [pull_request_review_event(303, submitted_at="2026-03-17T09:00:00Z", updated_at="2026-03-17T12:00:00Z", state="DISMISSED")], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "issues/42/comments?per_page=100&page=1", status_code=200, payload=[]) + .add_request("GET", "issues/42/comments?per_page=100&page=2", status_code=200, payload=[]) + .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(303, submitted_at="2026-03-17T09:00:00Z", updated_at="2026-03-17T12:00:00Z", state="DISMISSED")] - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert sweeper.sweep_deferred_gaps(runtime, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-17T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["reconciled_source_events"] = ["issue_comment:101", "pull_request_review:202"] - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"state": "open", "head": {"sha": "head-1"}}, - "issues/42/comments?per_page=100&page=1": [issue_comment_event(101, created_at="2026-03-17T10:00:00Z")], - }.get(endpoint), - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-17T11:00:00Z", state="APPROVED")], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request( + "GET", + "issues/42/comments?per_page=100&page=1", + status_code=200, + payload=[issue_comment_event(101, created_at="2026-03-17T10:00:00Z")], + ) + .add_request("GET", "issues/42/comments?per_page=100&page=2", status_code=200, payload=[]) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-submitted-observer.yml/runs?event=pull_request_review&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-17T11:00:00Z", state="APPROVED")] - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is False + assert sweeper.sweep_deferred_gaps(runtime, state) is False assert state["active_reviews"]["42"]["deferred_gaps"] == {} def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: [ + routes = RouteGitHubApi().add_request( + "GET", + "issues/42/comments?per_page=100&page=1", + status_code=200, + payload=[ issue_comment_event(100, created_at="2026-03-25T10:00:00Z", login="github-actions[bot]", user_type="Bot"), issue_comment_event(101, created_at="2026-03-25T11:00:00Z", login="alice"), ], ) + routes.add_request("GET", "issues/42/comments?per_page=100&page=2", status_code=200, payload=[]) + runtime.stub_github(routes) - discovered, complete = sweeper._discover_visible_comment_events(reviewer_bot, 42, review) + discovered, complete = sweeper._discover_visible_comment_events(runtime, 42, review) assert complete is True assert [item["source_event_key"] for item in discovered] == ["issue_comment:101"] @@ -154,28 +199,33 @@ def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(m def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_without_artifact(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" review["transition_warning_sent"] = "2026-03-18T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else {"workflow_runs": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-submitted-observer.yml/runs?event=pull_request_review&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request("GET", "issues/42/comments?per_page=100&page=1", status_code=200, payload=[]) + .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) + .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")] - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert sweeper.sweep_deferred_gaps(runtime, state) is True assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" assert review["transition_warning_sent"] is None assert review["transition_notice_sent_at"] is None @@ -185,8 +235,9 @@ def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_witho def test_visible_review_repair_does_not_clear_transition_warning_for_stale_replayed_review(monkeypatch, freeze_sweeper_now): freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" @@ -194,49 +245,42 @@ def test_visible_review_repair_does_not_clear_transition_warning_for_stale_repla review["transition_warning_sent"] = "2026-04-01T12:12:04Z" review["transition_notice_sent_at"] = "2026-04-15T12:12:04Z" review["deferred_gaps"]["pull_request_review:202"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: {"state": "open", "head": {"sha": "head-1"}} - if endpoint == "pulls/42" - else {"workflow_runs": []}, - ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")], + routes = ( + RouteGitHubApi() + .add_api("GET", "pulls/42", {"state": "open", "head": {"sha": "head-1"}}) + .add_request("GET", "pulls/42", status_code=200, payload={"state": "open", "head": {"sha": "head-1"}}) + .add_request( + "GET", + "actions/workflows/.github%2Fworkflows%2Freviewer-bot-pr-review-submitted-observer.yml/runs?event=pull_request_review&per_page=100&page=1", + status_code=200, + payload={"workflow_runs": []}, + ) + .add_request("GET", "issues/42/comments?per_page=100&page=1", status_code=200, payload=[]) + .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) + .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) + runtime.stub_github(routes) + runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")] - assert sweeper.sweep_deferred_gaps(reviewer_bot, state) is True + assert sweeper.sweep_deferred_gaps(runtime, state) is True assert review["last_reviewer_activity"] == "2026-03-25T11:00:00Z" assert review["transition_warning_sent"] == "2026-04-01T12:12:04Z" assert review["transition_notice_sent_at"] == "2026-04-15T12:12:04Z" def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(monkeypatch): - review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True) + runtime = _runtime(monkeypatch) + review = review_state.ensure_review_entry(make_state(), 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" review["deferred_gaps"]["pull_request_review:303"] = {"reason": "artifact_missing"} - monkeypatch.setattr( - reviewer_bot.sweeper_module, - "accept_reviewer_review_from_live_review", - lambda review_data, live_review, actor=None: False, - ) - monkeypatch.setattr( - reviewer_bot.sweeper_module, - "refresh_reviewer_review_from_live_preferred_review", - lambda bot, issue_number, review_data, actor=None: (False, None), - ) - monkeypatch.setattr( - reviewer_bot.sweeper_module, - "rebuild_pr_approval_state", - lambda bot, issue_number, review_data: (None, None), - ) + monkeypatch.setattr(sweeper, "accept_reviewer_review_from_live_review", lambda review_data, live_review, actor=None: False) + monkeypatch.setattr(sweeper, "refresh_reviewer_review_from_live_preferred_review", lambda bot, issue_number, review_data, actor=None: (False, None)) + monkeypatch.setattr(sweeper, "rebuild_pr_approval_state", lambda bot, issue_number, review_data: (None, None)) changed = sweeper._repair_visible_review_gap( - reviewer_bot, + runtime, review, 42, "pull_request_review:303", @@ -250,20 +294,8 @@ def test_repair_visible_review_gap_returns_true_for_bookkeeping_only_mutations(m def test_observer_run_reason_mapping_and_near_miss_signature(): signature = {"status": "waiting", "conclusion": None, "name": "approval_pending"} - assert ( - sweeper.observer_run_reason_from_details( - {"status": "waiting", "conclusion": None, "name": "approval_pending"}, - signature, - ) - == "awaiting_observer_approval" - ) - assert ( - sweeper.observer_run_reason_from_details( - {"status": "waiting", "conclusion": None, "name": "almost"}, - signature, - ) - == "observer_state_unknown" - ) + assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "approval_pending"}, signature) == "awaiting_observer_approval" + assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "almost"}, signature) == "observer_state_unknown" def test_negative_missing_run_requires_full_scan_and_recheck(): @@ -364,33 +396,16 @@ def test_evaluate_gap_state_completed_success_with_expired_artifact_marks_artifa def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention(): - expired = { - "artifact_seen_at": "2026-03-10T00:00:00Z", - "run_created_at": "2026-03-10T00:00:00Z", - } + expired = {"artifact_seen_at": "2026-03-10T00:00:00Z", "run_created_at": "2026-03-10T00:00:00Z"} assert sweeper.classify_artifact_gap_reason(expired) == "artifact_expired" - missing = { - "artifact_inspection_complete": True, - "run_created_at": "2026-03-17T00:00:00Z", - } + missing = {"artifact_inspection_complete": True, "run_created_at": "2026-03-17T00:00:00Z"} assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): - run_correlation = { - "candidate_run_ids": [123], - "correlated_run": None, - "correlated_run_found": False, - } - monkeypatch.setattr( - sweeper, - "_fetch_run_detail", - lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}, - ) - detail = sweeper._maybe_fetch_single_candidate_run_detail( - reviewer_bot, - run_correlation, - {"status": "no_exact_artifact_match"}, - ) + runtime = _runtime(monkeypatch) + run_correlation = {"candidate_run_ids": [123], "correlated_run": None, "correlated_run_found": False} + monkeypatch.setattr(sweeper, "_fetch_run_detail", lambda bot, run_id: {"id": run_id, "status": "completed", "conclusion": "action_required"}) + detail = sweeper._maybe_fetch_single_candidate_run_detail(runtime, run_correlation, {"status": "no_exact_artifact_match"}) assert detail == {"id": 123, "status": "completed", "conclusion": "action_required"} assert run_correlation["correlated_run"] == 123 From 730c8ab5303b16b8ca6598f2a249b0b427ca0ae3 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 02:06:00 +0900 Subject: [PATCH 102/209] refactor(reviewer-bot): complete import-boundary collapse --- scripts/reviewer_bot.py | 1136 +---------------- scripts/reviewer_bot_lib/bootstrap_runtime.py | 183 +++ scripts/reviewer_bot_lib/reconcile.py | 38 + scripts/reviewer_bot_lib/runtime.py | 9 + tests/conftest.py | 55 +- .../reviewer_bot/test_adapter_contract.py | 71 +- .../test_comment_application_contract.py | 16 +- .../test_workflow_artifact_contracts.py | 21 +- .../reviewer_bot/test_workflow_files.py | 16 +- tests/fixtures/app_harness.py | 8 +- tests/fixtures/commands_harness.py | 47 +- tests/fixtures/comment_routing_harness.py | 2 - tests/fixtures/reconcile_harness.py | 8 +- tests/fixtures/reviewer_bot_env.py | 6 +- .../test_accept_no_fls_changes.py | 31 +- .../test_app_closed_issue_cleanup.py | 4 +- .../test_app_reviewer_board_preview.py | 4 +- .../test_app_schedule_bookkeeping.py | 28 +- .../reviewer_bot/test_app_schedule_repairs.py | 18 +- .../test_app_workflow_run_bookkeeping.py | 26 +- .../test_app_workflow_run_paths.py | 12 +- .../test_lifecycle_integration.py | 78 +- .../test_reconcile_workflow_run.py | 12 +- tests/unit/reviewer_bot/test_commands.py | 105 +- .../unit/reviewer_bot/test_comment_routing.py | 13 +- tests/unit/reviewer_bot/test_lease_lock.py | 268 ++-- 26 files changed, 669 insertions(+), 1546 deletions(-) create mode 100644 scripts/reviewer_bot_lib/bootstrap_runtime.py diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index efb94c633..d987f0dd9 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -1,1149 +1,49 @@ #!/usr/bin/env python3 -""" -Reviewer Bot for Safety-Critical Rust Coding Guidelines - -This bot manages round-robin assignment of reviewers for coding guideline and -FLS audit issues and PRs. It supports commands for passing reviews, vacations, -and label management. - -All commands must be prefixed with @guidelines-bot /: - - @guidelines-bot /pass [reason] - - Skip the assigned reviewer for this issue/PR and assign the next person - - The skipped reviewer stays in queue position for future assignments - - @guidelines-bot /away YYYY-MM-DD [reason] - - Remove yourself from the queue until the specified date - - Automatically assigns the next available reviewer - - @guidelines-bot /claim - - Assign yourself as the reviewer for this issue/PR - - Removes any existing reviewer assignment - - @guidelines-bot /release [@username] [reason] - - Release your assignment from this issue/PR (or someone else's with triage+ permission) - - Does NOT auto-assign the next reviewer (use /pass for that) - - @guidelines-bot /rectify - - Reconcile this issue/PR's review state from GitHub review history - - Useful when cross-repo review events cannot persist state immediately - - @guidelines-bot /r? @username - - Assign a specific reviewer - - @guidelines-bot /r? producers - - Assign the next reviewer from the round-robin queue - - Useful for requesting a reviewer on an already-open issue/PR - - @guidelines-bot /label +label-name - - Add a label to the issue/PR - - @guidelines-bot /label -label-name - - Remove a label from the issue/PR - - @guidelines-bot /accept-no-fls-changes - - Update spec.lock and open a PR when the audit reports no guideline impact - - @guidelines-bot /sync-members - - Manually trigger sync of the queue with members.md - - @guidelines-bot /queue - - Show current queue status and who's next up - - @guidelines-bot /commands - - Show all available commands -""" +"""Bootstrap entrypoint for reviewer-bot.""" import sys -from collections.abc import Iterable -from datetime import datetime -from datetime import timezone as _timezone -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -# GitHub API interaction -timezone = _timezone - -import scripts.reviewer_bot_lib.automation as automation_module -import scripts.reviewer_bot_lib.commands as commands_module -import scripts.reviewer_bot_lib.comment_application as comment_application_module # noqa: F401 -import scripts.reviewer_bot_lib.comment_routing as comment_routing_module -import scripts.reviewer_bot_lib.events as events_module -import scripts.reviewer_bot_lib.github_api as github_api_module -import scripts.reviewer_bot_lib.lease_lock as lease_lock_module -import scripts.reviewer_bot_lib.lifecycle as lifecycle_module -import scripts.reviewer_bot_lib.maintenance as maintenance_module -import scripts.reviewer_bot_lib.project_board as project_board_module -import scripts.reviewer_bot_lib.reconcile as reconcile_module -import scripts.reviewer_bot_lib.review_state as review_state_module -import scripts.reviewer_bot_lib.reviews as reviews_module -import scripts.reviewer_bot_lib.state_store as state_store_module -import scripts.reviewer_bot_lib.sweeper as sweeper_module # noqa: F401 -from scripts.reviewer_bot_lib import app as app_module - -# Exported runtime config surface used by extracted modules and tests. -# Exported runtime types used by extracted modules and tests. -# Internal-only config consumed directly by the entrypoint adapter. -from scripts.reviewer_bot_lib.config import ( # noqa: F401 # noqa: F401 - AUTHOR_ASSOCIATION_TRUST_ALLOWLIST, - BOT_MENTION, - BOT_NAME, - COMMANDS, - DEFERRED_ARTIFACT_RETENTION_DAYS, - DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS, - DEFERRED_DISCOVERY_OVERLAP_SECONDS, - DEFERRED_MISSING_RUN_WINDOW_SECONDS, - EVENT_INTENT_MUTATING, - EVENT_INTENT_NON_MUTATING_DEFER, - EVENT_INTENT_NON_MUTATING_READONLY, - FLS_AUDIT_LABEL, - FRESHNESS_RUNTIME_EPOCH_LEGACY, - FRESHNESS_RUNTIME_EPOCH_V18, - LOCK_API_RETRY_LIMIT, - LOCK_BLOCK_END_MARKER, - LOCK_BLOCK_START_MARKER, - LOCK_COMMIT_MARKER, - LOCK_LEASE_TTL_SECONDS, - LOCK_MAX_WAIT_SECONDS, - LOCK_METADATA_KEYS, - LOCK_REF_BOOTSTRAP_BRANCH, - LOCK_REF_NAME, - LOCK_RENEWAL_WINDOW_SECONDS, - LOCK_RETRY_BASE_SECONDS, - LOCK_SCHEMA_VERSION, - MANDATORY_TRIAGE_APPROVER_LABEL, - MANDATORY_TRIAGE_ESCALATION_TEMPLATE, - MANDATORY_TRIAGE_SATISFIED_TEMPLATE, - MAX_RECENT_ASSIGNMENTS, - REVIEW_DEADLINE_DAYS, - REVIEW_FRESHNESS_RUNBOOK_PATH, - REVIEW_LABELS, - REVIEWER_BOARD_ENABLED_ENV, - REVIEWER_BOARD_FIELD_ASSIGNED_AT, - REVIEWER_BOARD_FIELD_NEEDS_ATTENTION, - REVIEWER_BOARD_FIELD_REVIEW_STATE, - REVIEWER_BOARD_FIELD_REVIEWER, - REVIEWER_BOARD_FIELD_WAITING_SINCE, - REVIEWER_BOARD_OPTION_ATTENTION_NO, - REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED, - REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT, - REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED, - REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT, - REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR, - REVIEWER_BOARD_OPTION_AWAITING_REVIEWER, - REVIEWER_BOARD_OPTION_AWAITING_WRITE_APPROVAL, - REVIEWER_BOARD_OPTION_DONE, - REVIEWER_BOARD_OPTION_UNASSIGNED, - REVIEWER_BOARD_ORG, - REVIEWER_BOARD_PROJECT_MANIFEST, - REVIEWER_BOARD_PROJECT_NUMBER, - REVIEWER_BOARD_TOKEN_ENV, - REVIEWER_REQUEST_422_TEMPLATE, - STATE_BLOCK_END_MARKER, - STATE_BLOCK_START_MARKER, - STATE_ISSUE_NUMBER, - STATE_READ_RETRY_BASE_SECONDS, - STATE_READ_RETRY_LIMIT, - STATE_SCHEMA_VERSION, - STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, - STATUS_AWAITING_REVIEW_COMPLETION_LABEL, - STATUS_AWAITING_REVIEWER_RESPONSE_LABEL, - STATUS_AWAITING_WRITE_APPROVAL_LABEL, - STATUS_LABEL_CONFIG, - STATUS_LABELS, - STATUS_PROJECTION_EPOCH, - TRANSITION_PERIOD_DAYS, - AssignmentAttempt, - GitHubApiResult, - LeaseContext, - StateIssueBodyParts, - StateIssueSnapshot, - get_commands_help, -) -from scripts.reviewer_bot_lib.context import ( # noqa: F401 - AssignmentRequest, - CommentEventRequest, - EventContext, - ExecutionResult, - PrCommentTrustContext, - PrivilegedCommandRequest, -) -from scripts.reviewer_bot_lib.guidance import ( # noqa: F401 - get_fls_audit_guidance, - get_issue_guidance, - get_pr_guidance, -) -from scripts.reviewer_bot_lib.members import fetch_members # noqa: F401 -from scripts.reviewer_bot_lib.queue import ( - get_next_reviewer as queue_get_next_reviewer, -) -from scripts.reviewer_bot_lib.queue import ( - process_pass_until_expirations as queue_process_pass_until_expirations, -) -from scripts.reviewer_bot_lib.queue import ( - record_assignment as queue_record_assignment, -) -from scripts.reviewer_bot_lib.queue import ( - reposition_member_as_next as queue_reposition_member_as_next, -) -from scripts.reviewer_bot_lib.queue import ( - sync_members_with_queue as queue_sync_members_with_queue, +from scripts.reviewer_bot_lib import github_api, lease_lock +from scripts.reviewer_bot_lib.app import build_event_context as build_app_event_context +from scripts.reviewer_bot_lib.app import execute_run as execute_app_run +from scripts.reviewer_bot_lib.app import main as run_app_main +from scripts.reviewer_bot_lib.bootstrap_runtime import ( + build_runtime as build_bootstrap_runtime, ) +from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime -requests = github_api_module.requests -random = lease_lock_module.random -time = lease_lock_module.time - -# ============================================================================== -# GitHub API Helpers -# ============================================================================== - - -ACTIVE_LEASE_CONTEXT: LeaseContext | None = None +ACTIVE_LEASE_CONTEXT = None TOUCHED_ISSUE_NUMBERS: set[int] = set() RUNTIME: ReviewerBotRuntime | None = None -def _runtime_bot() -> Any: +def _runtime_bot() -> ReviewerBotRuntime: if RUNTIME is None: raise RuntimeError("ReviewerBotRuntime not initialized") return RUNTIME -def get_config_value(name: str, default: str = "") -> str: - return _runtime_bot().get_config_value(name, default) - - -def set_config_value(name: str, value: Any) -> None: - _runtime_bot().set_config_value(name, value) - - -def write_output(name: str, value: str) -> None: - _runtime_bot().write_output(name, value) - - -def load_deferred_payload() -> dict: - return _runtime_bot().load_deferred_payload() - - -def get_github_token() -> str: - return github_api_module.get_github_token() - - -def get_github_graphql_token(*, prefer_board_token: bool = False) -> str: - return github_api_module.get_github_graphql_token( - _runtime_bot(), prefer_board_token=prefer_board_token - ) - - -def github_api_request( - method: str, - endpoint: str, - data: dict | None = None, - extra_headers: dict[str, str] | None = None, - *, - retry_policy: str = "none", - timeout_seconds: float | None = None, - suppress_error_log: bool = False, -) -> GitHubApiResult: - return github_api_module.github_api_request( - _runtime_bot(), - method, - endpoint, - data, - extra_headers, - retry_policy=retry_policy, - timeout_seconds=timeout_seconds, - suppress_error_log=suppress_error_log, - ) - - -def github_api(method: str, endpoint: str, data: dict | None = None) -> Any | None: - return github_api_module.github_api(_runtime_bot(), method, endpoint, data) - - -def github_graphql_request( - query: str, - variables: dict | None = None, - *, - token: str | None = None, - retry_policy: str = "none", - timeout_seconds: float | None = None, - suppress_error_log: bool = False, -) -> GitHubApiResult: - return github_api_module.github_graphql_request( - _runtime_bot(), - query, - variables, - token=token, - retry_policy=retry_policy, - timeout_seconds=timeout_seconds, - suppress_error_log=suppress_error_log, - ) - - -def github_graphql( - query: str, - variables: dict | None = None, - *, - token: str | None = None, -) -> Any | None: - return github_api_module.github_graphql(_runtime_bot(), query, variables, token=token) - - -def post_comment(issue_number: int, body: str) -> bool: - return github_api_module.post_comment(_runtime_bot(), issue_number, body) - - -def get_repo_labels() -> set[str]: - return github_api_module.get_repo_labels(_runtime_bot()) - - -def add_label(issue_number: int, label: str) -> bool: - return github_api_module.add_label(_runtime_bot(), issue_number, label) - - -def remove_label(issue_number: int, label: str) -> bool: - return github_api_module.remove_label(_runtime_bot(), issue_number, label) - - -def add_label_with_status(issue_number: int, label: str) -> bool: - return github_api_module.add_label_with_status(_runtime_bot(), issue_number, label) - - -def remove_label_with_status(issue_number: int, label: str) -> bool: - return github_api_module.remove_label_with_status(_runtime_bot(), issue_number, label) - - -def ensure_label_exists( - label: str, - *, - color: str | None = None, - description: str | None = None, -) -> bool: - return github_api_module.ensure_label_exists( - _runtime_bot(), - label, - color=color, - description=description, - ) - - -def collect_touched_item(issue_number: int | None) -> None: - """Record an issue/PR number for centralized status-label sync.""" - _runtime_bot().collect_touched_item(issue_number) - - -def drain_touched_items() -> list[int]: - """Return touched issue numbers and clear the collector.""" - return _runtime_bot().drain_touched_items() - - -def get_issue_or_pr_snapshot(issue_number: int) -> dict | None: - """Fetch issue metadata used for derived status labels.""" - result = github_api("GET", f"issues/{issue_number}") - if isinstance(result, dict): - return result - return None - - -def get_issue_or_pr_labels(issue_number: int) -> set[str] | None: - """Fetch the current label set for an issue or PR.""" - item = get_issue_or_pr_snapshot(issue_number) - if not isinstance(item, dict): - return None - - labels = item.get("labels", []) - if not isinstance(labels, list): - return set() - - result = set() - for label in labels: - if isinstance(label, dict): - name = label.get("name") - if isinstance(name, str): - result.add(name) - elif isinstance(label, str): - result.add(label) - return result - - -def request_reviewer_assignment(issue_number: int, username: str) -> AssignmentAttempt: - return github_api_module.request_reviewer_assignment(_runtime_bot(), issue_number, username) - - -def assign_reviewer(issue_number: int, username: str) -> bool: - return github_api_module.assign_reviewer(_runtime_bot(), issue_number, username) - - -def get_assignment_failure_comment(reviewer: str, attempt: AssignmentAttempt) -> str | None: - return github_api_module.get_assignment_failure_comment(_runtime_bot(), reviewer, attempt) - - -def get_issue_assignees(issue_number: int) -> list[str] | None: - return github_api_module.get_issue_assignees(_runtime_bot(), issue_number) - - -def add_reaction(comment_id: int, reaction: str) -> bool: - return github_api_module.add_reaction(_runtime_bot(), comment_id, reaction) - - -def remove_assignee(issue_number: int, username: str) -> bool: - return github_api_module.remove_assignee(_runtime_bot(), issue_number, username) - - -def remove_pr_reviewer(issue_number: int, username: str) -> bool: - return github_api_module.remove_pr_reviewer(_runtime_bot(), issue_number, username) - - -def unassign_reviewer(issue_number: int, username: str) -> bool: - return github_api_module.unassign_reviewer(_runtime_bot(), issue_number, username) - - -def get_user_permission_status(username: str, required_permission: str = "triage") -> str: - return github_api_module.get_user_permission_status(_runtime_bot(), username, required_permission) - - -def check_user_permission(username: str, required_permission: str = "triage") -> bool | None: - return github_api_module.check_user_permission(_runtime_bot(), username, required_permission) - - -# ============================================================================== -# State Management -# ============================================================================== - - -def get_state_issue() -> dict | None: - return state_store_module.get_state_issue(_runtime_bot()) - - -def default_state_issue_prefix() -> str: - return state_store_module.default_state_issue_prefix() - - -def split_state_issue_body(body: str) -> StateIssueBodyParts: - return state_store_module.split_state_issue_body(body) - - -def extract_fenced_block(inner_block: str, language_pattern: str) -> str | None: - return state_store_module.extract_fenced_block(inner_block, language_pattern) - - -def normalize_lock_metadata(lock_meta: dict | None) -> dict: - return state_store_module.normalize_lock_metadata(lock_meta) - - -def parse_state_yaml_from_issue_body(body: str) -> dict: - return state_store_module.parse_state_yaml_from_issue_body(body) - - -def parse_lock_metadata_from_issue_body(body: str) -> dict: - return state_store_module.parse_lock_metadata_from_issue_body(body) - - -def render_marked_fenced_block( - start_marker: str, - end_marker: str, - language: str, - content: str, -) -> str: - return state_store_module.render_marked_fenced_block(start_marker, end_marker, language, content) - - -def render_state_issue_body( - state: dict, - lock_meta: dict, - base_body: str | None = None, - *, - preserve_state_block: bool = False, -) -> str: - return state_store_module.render_state_issue_body( - state, - lock_meta, - base_body, - preserve_state_block=preserve_state_block, - ) - - -def parse_state_from_issue(issue: dict) -> dict: - return state_store_module.parse_state_from_issue(issue) - - -def get_state_issue_snapshot() -> StateIssueSnapshot | None: - return state_store_module.get_state_issue_snapshot(_runtime_bot()) - - -def conditional_patch_state_issue(body: str, etag: str | None = None) -> GitHubApiResult: - return state_store_module.conditional_patch_state_issue(_runtime_bot(), body, etag) - - -def assert_lock_held(operation: str) -> None: - state_store_module.assert_lock_held(_runtime_bot(), operation) - - -def load_state(*, fail_on_unavailable: bool = False) -> dict: - return state_store_module.load_state(_runtime_bot(), fail_on_unavailable=fail_on_unavailable) - - -def save_state(state: dict) -> bool: - return state_store_module.save_state(_runtime_bot(), state) - - -def parse_iso8601_timestamp(value: Any) -> datetime | None: - return state_store_module.parse_iso8601_timestamp(value) - - -def lock_is_currently_valid(lock_meta: dict, now: datetime | None = None) -> bool: - return lease_lock_module.lock_is_currently_valid(_runtime_bot(), lock_meta, now) - - -def get_lock_owner_context() -> tuple[str, str, str]: - return lease_lock_module.get_lock_owner_context() - - -def build_lock_metadata( - lock_token: str, - lock_owner_run_id: str, - lock_owner_workflow: str, - lock_owner_job: str, -) -> dict: - return lease_lock_module.build_lock_metadata( - _runtime_bot(), lock_token, lock_owner_run_id, lock_owner_workflow, lock_owner_job - ) - - -def clear_lock_metadata() -> dict: - return lease_lock_module.clear_lock_metadata(_runtime_bot()) - - -def normalize_lock_ref_name(ref_name: str) -> str: - return lease_lock_module.normalize_lock_ref_name(ref_name) - - -def get_lock_ref_name() -> str: - return lease_lock_module.get_lock_ref_name(_runtime_bot()) - - -def get_lock_ref_display() -> str: - return lease_lock_module.get_lock_ref_display(_runtime_bot()) - - -def get_state_issue_html_url() -> str: - return lease_lock_module.get_state_issue_html_url(_runtime_bot()) - - -def extract_ref_sha(payload: Any) -> str | None: - return lease_lock_module.extract_ref_sha(payload) - - -def extract_commit_tree_sha(payload: Any) -> str | None: - return lease_lock_module.extract_commit_tree_sha(payload) - - -def extract_commit_sha(payload: Any) -> str | None: - return lease_lock_module.extract_commit_sha(payload) - - -def render_lock_commit_message(lock_meta: dict) -> str: - return lease_lock_module.render_lock_commit_message(_runtime_bot(), lock_meta) - - -def parse_lock_metadata_from_lock_commit_message(message: str) -> dict: - return lease_lock_module.parse_lock_metadata_from_lock_commit_message(_runtime_bot(), message) - - -def ensure_lock_ref_exists() -> str: - return lease_lock_module.ensure_lock_ref_exists(_runtime_bot()) - - -def get_lock_ref_snapshot() -> tuple[str, str, dict]: - return lease_lock_module.get_lock_ref_snapshot(_runtime_bot()) - - -def create_lock_commit(parent_sha: str, tree_sha: str, lock_meta: dict) -> GitHubApiResult: - return lease_lock_module.create_lock_commit(_runtime_bot(), parent_sha, tree_sha, lock_meta) - - -def cas_update_lock_ref(new_sha: str) -> GitHubApiResult: - return lease_lock_module.cas_update_lock_ref(_runtime_bot(), new_sha) - - -def ensure_state_issue_lease_lock_fresh() -> bool: - return lease_lock_module.ensure_state_issue_lease_lock_fresh(_runtime_bot()) - - -def renew_state_issue_lease_lock(context: LeaseContext) -> bool: - result = lease_lock_module.renew_state_issue_lease_lock(_runtime_bot(), context) - globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT - return result - - -def acquire_state_issue_lease_lock() -> LeaseContext: - context = lease_lock_module.acquire_state_issue_lease_lock(_runtime_bot()) - globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT - return context - - -def release_state_issue_lease_lock() -> bool: - result = lease_lock_module.release_state_issue_lease_lock(_runtime_bot()) - globals()["ACTIVE_LEASE_CONTEXT"] = _runtime_bot().ACTIVE_LEASE_CONTEXT - return result - - -def sync_members_with_queue(state: dict) -> tuple[dict, list[str]]: - return queue_sync_members_with_queue(_runtime_bot(), state) - - -def reposition_member_as_next(state: dict, username: str) -> bool: - return queue_reposition_member_as_next(state, username) - - -def process_pass_until_expirations(state: dict) -> tuple[dict, list[str]]: - return queue_process_pass_until_expirations(state) - - -# ============================================================================== -# Reviewer Assignment -# ============================================================================== - - -def get_next_reviewer(state: dict, skip_usernames: set[str] | None = None) -> str | None: - return queue_get_next_reviewer(state, skip_usernames) - - -def record_assignment(state: dict, github: str, issue_number: int, - issue_type: str) -> None: - queue_record_assignment( - state, - github, - issue_number, - issue_type, - max_recent_assignments=MAX_RECENT_ASSIGNMENTS, - ) - - -# ============================================================================== -# Command Parsing & Handling -# ============================================================================== - - -def strip_code_blocks(comment_body: str) -> str: - return commands_module.strip_code_blocks(comment_body) - - -def parse_command(comment_body: str) -> tuple[str, list[str]] | None: - return commands_module.parse_command(_runtime_bot(), comment_body) - - -def handle_pass_command(state: dict, issue_number: int, comment_author: str, - reason: str | None) -> tuple[str, bool]: - return commands_module.handle_pass_command( - _runtime_bot(), - state, - issue_number, - comment_author, - reason, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def handle_pass_until_command(state: dict, issue_number: int, comment_author: str, - return_date: str, reason: str | None) -> tuple[str, bool]: - return commands_module.handle_pass_until_command( - _runtime_bot(), - state, - issue_number, - comment_author, - return_date, - reason, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def handle_label_command(state: dict, issue_number: int, label_string: str) -> tuple[str, bool, bool]: - return commands_module.handle_label_command( - _runtime_bot(), - state, - issue_number, - label_string, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def parse_issue_labels() -> list[str]: - return commands_module.parse_issue_labels() - - -def run_command(command: list[str], cwd: Path, check: bool = True) -> Any: - return automation_module.run_command(command, cwd, check=check) - - -def summarize_output(result: Any, limit: int = 20) -> str: - return automation_module.summarize_output(result, limit=limit) - - -def list_changed_files(repo_root: Path) -> list[str]: - return automation_module.list_changed_files(repo_root) - - -def get_default_branch() -> str: - return automation_module.get_default_branch(_runtime_bot()) - - -def find_open_pr_for_branch(branch: str) -> dict | None: - return automation_module.find_open_pr_for_branch(_runtime_bot(), branch) - - -def find_open_pr_for_branch_status(branch: str) -> tuple[str, dict | None]: - return automation_module.find_open_pr_for_branch_status(_runtime_bot(), branch) - - -def resolve_workflow_run_pr_number() -> int: - return commands_module.resolve_workflow_run_pr_number( - _runtime_bot(), - request=commands_module.build_privileged_command_request(issue_number=0), - ) - - -def create_pull_request(branch: str, base: str, issue_number: int) -> dict | None: - return automation_module.create_pull_request(_runtime_bot(), branch, base, issue_number) - - -def handle_accept_no_fls_changes_command(issue_number: int, comment_author: str) -> tuple[str, bool]: - return automation_module.handle_accept_no_fls_changes_command( - _runtime_bot(), - issue_number, - comment_author, - request=automation_module.build_privileged_command_request( - issue_number=issue_number, - actor=comment_author, - command_name="accept-no-fls-changes", - ), - ) - - -def handle_sync_members_command(state: dict) -> tuple[str, bool]: - return commands_module.handle_sync_members_command(_runtime_bot(), state) - - -def handle_queue_command(state: dict) -> tuple[str, bool]: - return commands_module.handle_queue_command( - _runtime_bot(), - state, - request=commands_module.build_assignment_request(issue_number=0), - ) - - -def handle_commands_command() -> tuple[str, bool]: - return commands_module.handle_commands_command(_runtime_bot()) - - -def handle_claim_command(state: dict, issue_number: int, - comment_author: str) -> tuple[str, bool]: - return commands_module.handle_claim_command( - _runtime_bot(), - state, - issue_number, - comment_author, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def handle_release_command(state: dict, issue_number: int, - comment_author: str, args: list | None = None) -> tuple[str, bool]: - return commands_module.handle_release_command( - _runtime_bot(), - state, - issue_number, - comment_author, - args, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def handle_assign_command(state: dict, issue_number: int, - username: str) -> tuple[str, bool]: - return commands_module.handle_assign_command( - _runtime_bot(), - state, - issue_number, - username, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -def handle_assign_from_queue_command(state: dict, issue_number: int) -> tuple[str, bool]: - return commands_module.handle_assign_from_queue_command( - _runtime_bot(), - state, - issue_number, - request=commands_module.build_assignment_request(issue_number=issue_number), - ) - - -# ============================================================================== -# Event Handlers -# ============================================================================== - - -def ensure_review_entry(state: dict, issue_number: int, create: bool = False) -> dict | None: - return review_state_module.ensure_review_entry(state, issue_number, create=create) - - -def set_current_reviewer(state: dict, issue_number: int, reviewer: str, - assignment_method: str = "round-robin") -> None: - review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) - - -def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: - return review_state_module.update_reviewer_activity(state, issue_number, reviewer) - - -def mark_review_complete( - state: dict, - issue_number: int, - reviewer: str | None, - source: str, -) -> bool: - return review_state_module.mark_review_complete(state, issue_number, reviewer, source) - - -def is_triage_or_higher(username: str) -> bool: - return reviews_module.is_triage_or_higher(_runtime_bot(), username) - - -def trigger_mandatory_approver_escalation(state: dict, issue_number: int) -> bool: - return reviews_module.trigger_mandatory_approver_escalation(_runtime_bot(), state, issue_number) - - -def satisfy_mandatory_approver_requirement( - state: dict, - issue_number: int, - approver: str, -) -> bool: - return reviews_module.satisfy_mandatory_approver_requirement( - _runtime_bot(), state, issue_number, approver - ) - - -def handle_pr_approved_review( - state: dict, - issue_number: int, - review_author: str, - completion_source: str, -) -> bool: - return reviews_module.handle_pr_approved_review( - _runtime_bot(), state, issue_number, review_author, completion_source - ) - - -def parse_github_timestamp(value: str | None) -> datetime | None: - return reviews_module.parse_github_timestamp(value) - - -def get_pull_request_reviews(issue_number: int) -> list[dict] | None: - return reviews_module.get_pull_request_reviews(_runtime_bot(), issue_number) - - -get_pull_request_reviews._reviewer_bot_canonical = True - - -def compute_reviewer_response_state( - issue_number: int, - review_data: dict, - *, - issue_snapshot: dict | None = None, - pull_request: dict | None = None, - reviews: list[dict] | None = None, -) -> dict[str, object]: - return reviews_module.compute_reviewer_response_state( - _runtime_bot(), - issue_number, - review_data, - issue_snapshot=issue_snapshot, - pull_request=pull_request, - reviews=reviews, - ) - - -def project_status_labels_for_item( - issue_number: int, - state: dict, - *, - issue_snapshot: dict | None = None, -) -> tuple[set[str] | None, dict[str, str | None]]: - return reviews_module.project_status_labels_for_item( - _runtime_bot(), issue_number, state, issue_snapshot=issue_snapshot - ) - - -def sync_status_labels(issue_number: int, desired_labels: set[str], actual_labels: Iterable[str]) -> bool: - return reviews_module.sync_status_labels(_runtime_bot(), issue_number, desired_labels, actual_labels) - - -def sync_status_labels_for_items(state: dict, issue_numbers: Iterable[int]) -> bool: - return reviews_module.sync_status_labels_for_items(_runtime_bot(), state, issue_numbers) - - -def list_open_items_with_status_labels() -> list[int]: - return reviews_module.list_open_items_with_status_labels(_runtime_bot()) - - -def handle_rectify_command(state: dict, issue_number: int, comment_author: str) -> tuple[str, bool, bool]: - """Handle /rectify for the current issue/PR only. - - Permission model: - - Allowed for the currently assigned reviewer. - - Allowed for users with triage+ permissions. - - Returns (message, success, state_changed). - """ - review_data = ensure_review_entry(state, issue_number) - current_reviewer = review_data.get("current_reviewer") if review_data else None - - is_current_reviewer = ( - isinstance(current_reviewer, str) - and current_reviewer.lower() == comment_author.lower() - ) - - triage_status = "denied" - if not is_current_reviewer: - triage_status = get_user_permission_status(comment_author, "triage") - - if not is_current_reviewer and triage_status == "unavailable": - return ( - "❌ Unable to verify triage permissions right now; refusing to continue.", - False, - False, - ) - - if not is_current_reviewer and triage_status != "granted": - if current_reviewer: - return ( - f"❌ Only the assigned reviewer (@{current_reviewer}) or a maintainer with triage+ " - "permission can run `/rectify`.", - False, - False, - ) - return ( - "❌ Only maintainers with triage+ permission can run `/rectify` when no assigned " - "reviewer is tracked.", - False, - False, - ) - - return reconcile_module.reconcile_active_review_entry( - _runtime_bot(), - state, - issue_number, - ) - - -def handle_transition_notice(state: dict, issue_number: int, reviewer: str) -> bool: - return lifecycle_module.handle_transition_notice(_runtime_bot(), state, issue_number, reviewer) - - -def handle_issue_or_pr_opened(state: dict) -> bool: - return lifecycle_module.handle_issue_or_pr_opened(_runtime_bot(), state) - - -def handle_labeled_event(state: dict) -> bool: - return lifecycle_module.handle_labeled_event(_runtime_bot(), state) - - -def handle_issue_edited_event(state: dict) -> bool: - return lifecycle_module.handle_issue_edited_event(_runtime_bot(), state) - - -def handle_pull_request_target_synchronize(state: dict) -> bool: - return lifecycle_module.handle_pull_request_target_synchronize(_runtime_bot(), state) - - -def maybe_record_head_observation_repair( - issue_number: int, review_data: dict -) -> lifecycle_module.HeadObservationRepairResult: - return lifecycle_module.maybe_record_head_observation_repair(_runtime_bot(), issue_number, review_data) - - -def handle_pull_request_review_event(state: dict) -> bool: - return events_module.handle_pull_request_review_event(_runtime_bot(), state) - - -def handle_workflow_run_event(state: dict) -> bool: - return reconcile_module.handle_workflow_run_event(_runtime_bot(), state) - - -def handle_closed_event(state: dict) -> bool: - return lifecycle_module.handle_closed_event(_runtime_bot(), state) - - -def handle_comment_event(state: dict) -> bool: - return comment_routing_module.handle_comment_event( - _runtime_bot(), - state, - comment_routing_module.build_comment_event_request(), - comment_routing_module.build_pr_comment_trust_context(), - ) - - -def build_pr_comment_observer_payload(issue_number: int) -> dict: - return comment_routing_module.build_pr_comment_observer_payload( - _runtime_bot(), - issue_number, - comment_routing_module.build_comment_event_request(issue_number=issue_number), - comment_routing_module.build_pr_comment_trust_context(), - ) - - -def handle_manual_dispatch(state: dict) -> bool: - return maintenance_module.handle_manual_dispatch(_runtime_bot(), state) - - -def handle_scheduled_check(state: dict) -> bool: - return maintenance_module.handle_scheduled_check(_runtime_bot(), state) - - -def reviewer_board_preflight(): - return project_board_module.reviewer_board_preflight(_runtime_bot()) - - -def preview_board_projection_for_item(state: dict, issue_number: int): - return project_board_module.preview_board_projection_for_item(_runtime_bot(), state, issue_number) - - -# ============================================================================== -# Main -# ============================================================================== - - -def classify_event_intent(event_name: str, event_action: str) -> str: - return app_module.classify_event_intent(_runtime_bot(), event_name, event_action) - - -def event_requires_lease_lock(event_name: str, event_action: str) -> bool: - """Backwards-compatible helper for tests and call sites.""" - return app_module.event_requires_lease_lock(_runtime_bot(), event_name, event_action) - - def build_event_context() -> EventContext: - return app_module.build_event_context(_runtime_bot()) + return build_app_event_context(_runtime_bot()) def execute_run(context: EventContext) -> ExecutionResult: - return app_module.execute_run(_runtime_bot(), context) + return execute_app_run(_runtime_bot(), context) -def main(): - app_module.main(_runtime_bot()) +def main() -> None: + run_app_main(_runtime_bot()) def _build_runtime() -> ReviewerBotRuntime: - runtime: ReviewerBotRuntime | None = None - state_store = SimpleNamespace( - load_state=lambda *, fail_on_unavailable=False: state_store_module.load_state(runtime, fail_on_unavailable=fail_on_unavailable), - save_state=lambda state: state_store_module.save_state(runtime, state), - ) - github = SimpleNamespace( - github_api_request=lambda *args, **kwargs: github_api_module.github_api_request(runtime, *args, **kwargs), - github_api=lambda *args, **kwargs: github_api_module.github_api(runtime, *args, **kwargs), - ) - locks = SimpleNamespace() - handlers = SimpleNamespace( - handle_issue_or_pr_opened=lambda state: handle_issue_or_pr_opened(state), - handle_labeled_event=lambda state: handle_labeled_event(state), - handle_issue_edited_event=lambda state: handle_issue_edited_event(state), - handle_closed_event=lambda state: handle_closed_event(state), - handle_pull_request_target_synchronize=lambda state: handle_pull_request_target_synchronize(state), - handle_pull_request_review_event=lambda state: handle_pull_request_review_event(state), - handle_comment_event=lambda state: handle_comment_event(state), - handle_manual_dispatch=lambda state: handle_manual_dispatch(state), - handle_scheduled_check=lambda state: handle_scheduled_check(state), - handle_workflow_run_event=lambda state: handle_workflow_run_event(state), - ) - adapters = SimpleNamespace( - assert_lock_held=lambda context: state_store_module.assert_lock_held(runtime, context), - get_github_token=github_api_module.get_github_token, - get_github_graphql_token=lambda *, prefer_board_token=False: github_api_module.get_github_graphql_token(runtime, prefer_board_token=prefer_board_token), - github_graphql=lambda query, variables=None, *, token=None: github_api_module.github_graphql(runtime, query, variables, token=token), - post_comment=lambda issue_number, body: github_api_module.post_comment(runtime, issue_number, body), - get_repo_labels=lambda: github_api_module.get_repo_labels(runtime), - add_label=lambda issue_number, label: github_api_module.add_label(runtime, issue_number, label), - remove_label=lambda issue_number, label: github_api_module.remove_label(runtime, issue_number, label), - ensure_label_exists=lambda label, *, color=None, description=None: github_api_module.ensure_label_exists(runtime, label, color=color, description=description), - get_issue_assignees=lambda issue_number: github_api_module.get_issue_assignees(runtime, issue_number), - request_reviewer_assignment=lambda issue_number, username: github_api_module.request_reviewer_assignment(runtime, issue_number, username), - get_assignment_failure_comment=lambda reviewer, attempt: github_api_module.get_assignment_failure_comment(runtime, reviewer, attempt), - add_reaction=lambda comment_id, reaction: github_api_module.add_reaction(runtime, comment_id, reaction), - remove_assignee=lambda issue_number, username: github_api_module.remove_assignee(runtime, issue_number, username), - remove_pr_reviewer=lambda issue_number, username: github_api_module.remove_pr_reviewer(runtime, issue_number, username), - unassign_reviewer=lambda issue_number, username: github_api_module.unassign_reviewer(runtime, issue_number, username), - get_user_permission_status=lambda username, required_permission="triage": github_api_module.get_user_permission_status(runtime, username, required_permission), - check_user_permission=lambda username, required_permission="triage": github_api_module.check_user_permission(runtime, username, required_permission), - get_issue_or_pr_snapshot=lambda issue_number: get_issue_or_pr_snapshot(issue_number), - get_pull_request_reviews=lambda issue_number: reviews_module.get_pull_request_reviews(runtime, issue_number), - maybe_record_head_observation_repair=lambda issue_number, review_data: lifecycle_module.maybe_record_head_observation_repair(runtime, issue_number, review_data), - ensure_review_entry=lambda state, issue_number, create=False: review_state_module.ensure_review_entry(state, issue_number, create=create), - set_current_reviewer=lambda state, issue_number, reviewer, assignment_method="round-robin": review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method), - update_reviewer_activity=lambda state, issue_number, reviewer: review_state_module.update_reviewer_activity(state, issue_number, reviewer), - mark_review_complete=lambda state, issue_number, reviewer, source: review_state_module.mark_review_complete(state, issue_number, reviewer, source), - is_triage_or_higher=lambda username: reviews_module.is_triage_or_higher(runtime, username), - trigger_mandatory_approver_escalation=lambda state, issue_number: reviews_module.trigger_mandatory_approver_escalation(runtime, state, issue_number), - satisfy_mandatory_approver_requirement=lambda state, issue_number, approver: reviews_module.satisfy_mandatory_approver_requirement(runtime, state, issue_number, approver), - get_next_reviewer=lambda state, skip_usernames=None: queue_get_next_reviewer(state, skip_usernames), - strip_code_blocks=commands_module.strip_code_blocks, - parse_command=lambda comment_body: commands_module.parse_command(runtime, comment_body), - record_assignment=queue_record_assignment, - reposition_member_as_next=queue_reposition_member_as_next, - parse_iso8601_timestamp=state_store_module.parse_iso8601_timestamp, - compute_reviewer_response_state=lambda issue_number, review_data, *, issue_snapshot=None: reviews_module.compute_reviewer_response_state(runtime, issue_number, review_data, issue_snapshot=issue_snapshot), - run_command=automation_module.run_command, - summarize_output=automation_module.summarize_output, - list_changed_files=automation_module.list_changed_files, - get_default_branch=lambda: github_api_module.get_default_branch(runtime), - find_open_pr_for_branch_status=lambda branch: automation_module.find_open_pr_for_branch_status(runtime, branch), - create_pull_request=lambda branch, base, issue_number: automation_module.create_pull_request(runtime, branch, base, issue_number), - parse_issue_labels=automation_module.bot_parse_issue_labels, - normalize_lock_metadata=state_store_module.normalize_lock_metadata, - get_state_issue=lambda: state_store_module.get_state_issue(runtime), - clear_lock_metadata=lambda: lease_lock_module.clear_lock_metadata(runtime), - get_state_issue_snapshot=lambda: state_store_module.get_state_issue_snapshot(runtime), - conditional_patch_state_issue=lambda body, etag=None: state_store_module.conditional_patch_state_issue(runtime, body, etag), - parse_lock_metadata_from_issue_body=state_store_module.parse_lock_metadata_from_issue_body, - render_state_issue_body=lambda state, lock_meta, base_body=None, *, preserve_state_block=False: state_store_module.render_state_issue_body( - state, - lock_meta, - base_body, - preserve_state_block=preserve_state_block, - ), - get_state_issue_html_url=lambda: lease_lock_module.get_state_issue_html_url(runtime), - get_lock_ref_display=lambda: lease_lock_module.get_lock_ref_display(runtime), - get_lock_ref_snapshot=lambda: lease_lock_module.get_lock_ref_snapshot(runtime), - build_lock_metadata=lambda *args, **kwargs: lease_lock_module.build_lock_metadata(runtime, *args, **kwargs), - create_lock_commit=lambda parent_sha, tree_sha, lock_meta: lease_lock_module.create_lock_commit(runtime, parent_sha, tree_sha, lock_meta), - cas_update_lock_ref=lambda new_sha: lease_lock_module.cas_update_lock_ref(runtime, new_sha), - lock_is_currently_valid=lambda lock_meta, now=None: lease_lock_module.lock_is_currently_valid(runtime, lock_meta, now), - renew_state_issue_lease_lock=lambda context: lease_lock_module.renew_state_issue_lease_lock(runtime, context), - ensure_state_issue_lease_lock_fresh=lambda: lease_lock_module.ensure_state_issue_lease_lock_fresh(runtime), - acquire_state_issue_lease_lock=lambda: lease_lock_module.acquire_state_issue_lease_lock(runtime), - release_state_issue_lease_lock=lambda: lease_lock_module.release_state_issue_lease_lock(runtime), - get_active_lease_context=lambda: runtime.ACTIVE_LEASE_CONTEXT, - ) - runtime = ReviewerBotRuntime( - requests=requests, + return build_bootstrap_runtime( + requests=github_api.requests, sys=sys, - random=random, - time=time, - state_store=state_store, - github=github, - locks=locks, - handlers=handlers, - adapters=adapters, + random=lease_lock.random, + time=lease_lock.time, active_lease_context=ACTIVE_LEASE_CONTEXT, ) - return runtime RUNTIME = _build_runtime() diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py new file mode 100644 index 000000000..fad4e6dbd --- /dev/null +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -0,0 +1,183 @@ +"""Bootstrap runtime composition for the reviewer-bot entrypoint.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from . import ( + automation, + commands, + comment_routing, + events, + github_api, + lease_lock, + lifecycle, + maintenance, + reconcile, + review_state, + reviews, + state_store, +) +from .queue import ( + get_next_reviewer, + process_pass_until_expirations, + record_assignment, + reposition_member_as_next, + sync_members_with_queue, +) +from .runtime import ReviewerBotRuntime + + +def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: + runtime: ReviewerBotRuntime | None = None + + state_store_services = SimpleNamespace( + load_state=lambda *, fail_on_unavailable=False: state_store.load_state( + runtime, fail_on_unavailable=fail_on_unavailable + ), + save_state=lambda current_state: state_store.save_state(runtime, current_state), + ) + github_services = SimpleNamespace( + github_api_request=lambda *args, **kwargs: github_api.github_api_request( + runtime, *args, **kwargs + ), + github_api=lambda *args, **kwargs: github_api.github_api(runtime, *args, **kwargs), + ) + lock_services = SimpleNamespace() + handlers = SimpleNamespace( + handle_issue_or_pr_opened=lambda current_state: lifecycle.handle_issue_or_pr_opened(runtime, current_state), + handle_labeled_event=lambda current_state: lifecycle.handle_labeled_event(runtime, current_state), + handle_issue_edited_event=lambda current_state: lifecycle.handle_issue_edited_event(runtime, current_state), + handle_closed_event=lambda current_state: lifecycle.handle_closed_event(runtime, current_state), + handle_pull_request_target_synchronize=lambda current_state: lifecycle.handle_pull_request_target_synchronize( + runtime, current_state + ), + handle_pull_request_review_event=lambda current_state: events.handle_pull_request_review_event(runtime, current_state), + handle_comment_event=lambda current_state: comment_routing.handle_comment_event(runtime, current_state), + handle_manual_dispatch=lambda current_state: maintenance.handle_manual_dispatch(runtime, current_state), + handle_scheduled_check=lambda current_state: maintenance.handle_scheduled_check(runtime, current_state), + handle_workflow_run_event=lambda current_state: reconcile.handle_workflow_run_event(runtime, current_state), + ) + adapters = SimpleNamespace( + assert_lock_held=lambda context: state_store.assert_lock_held(runtime, context), + get_github_token=github_api.get_github_token, + get_github_graphql_token=lambda *, prefer_board_token=False: github_api.get_github_graphql_token( + runtime, prefer_board_token=prefer_board_token + ), + github_graphql=lambda query, variables=None, *, token=None: github_api.github_graphql( + runtime, query, variables, token=token + ), + post_comment=lambda issue_number, body: github_api.post_comment(runtime, issue_number, body), + get_repo_labels=lambda: github_api.get_repo_labels(runtime), + add_label=lambda issue_number, label: github_api.add_label(runtime, issue_number, label), + remove_label=lambda issue_number, label: github_api.remove_label(runtime, issue_number, label), + ensure_label_exists=lambda label, *, color=None, description=None: github_api.ensure_label_exists( + runtime, label, color=color, description=description + ), + get_issue_assignees=lambda issue_number: github_api.get_issue_assignees(runtime, issue_number), + request_reviewer_assignment=lambda issue_number, username: github_api.request_reviewer_assignment( + runtime, issue_number, username + ), + get_assignment_failure_comment=lambda reviewer, attempt: github_api.get_assignment_failure_comment( + runtime, reviewer, attempt + ), + add_reaction=lambda comment_id, reaction: github_api.add_reaction(runtime, comment_id, reaction), + remove_assignee=lambda issue_number, username: github_api.remove_assignee(runtime, issue_number, username), + remove_pr_reviewer=lambda issue_number, username: github_api.remove_pr_reviewer(runtime, issue_number, username), + unassign_reviewer=lambda issue_number, username: github_api.unassign_reviewer(runtime, issue_number, username), + get_user_permission_status=lambda username, required_permission="triage": github_api.get_user_permission_status( + runtime, username, required_permission + ), + check_user_permission=lambda username, required_permission="triage": github_api.check_user_permission( + runtime, username, required_permission + ), + get_issue_or_pr_snapshot=lambda issue_number: github_api.github_api(runtime, "GET", f"issues/{issue_number}"), + get_pull_request_reviews=lambda issue_number: reviews.get_pull_request_reviews(runtime, issue_number), + maybe_record_head_observation_repair=lambda issue_number, review_data: lifecycle.maybe_record_head_observation_repair( + runtime, issue_number, review_data + ), + ensure_review_entry=lambda current_state, issue_number, create=False: review_state.ensure_review_entry( + current_state, issue_number, create=create + ), + set_current_reviewer=lambda current_state, issue_number, reviewer, assignment_method="round-robin": review_state.set_current_reviewer( + current_state, issue_number, reviewer, assignment_method=assignment_method + ), + update_reviewer_activity=lambda current_state, issue_number, reviewer: review_state.update_reviewer_activity( + current_state, issue_number, reviewer + ), + mark_review_complete=lambda current_state, issue_number, reviewer, source: review_state.mark_review_complete( + current_state, issue_number, reviewer, source + ), + is_triage_or_higher=lambda username: reviews.is_triage_or_higher(runtime, username), + trigger_mandatory_approver_escalation=lambda current_state, issue_number: reviews.trigger_mandatory_approver_escalation( + runtime, current_state, issue_number + ), + satisfy_mandatory_approver_requirement=lambda current_state, issue_number, approver: reviews.satisfy_mandatory_approver_requirement( + runtime, current_state, issue_number, approver + ), + get_next_reviewer=get_next_reviewer, + strip_code_blocks=commands.strip_code_blocks, + parse_command=lambda comment_body: commands.parse_command(runtime, comment_body), + record_assignment=record_assignment, + reposition_member_as_next=reposition_member_as_next, + process_pass_until_expirations=process_pass_until_expirations, + sync_members_with_queue=lambda current_state: sync_members_with_queue(runtime, current_state), + parse_iso8601_timestamp=state_store.parse_iso8601_timestamp, + compute_reviewer_response_state=lambda issue_number, review_data, *, issue_snapshot=None: reviews.compute_reviewer_response_state( + runtime, issue_number, review_data, issue_snapshot=issue_snapshot + ), + sync_status_labels_for_items=lambda current_state, issue_numbers: reviews.sync_status_labels_for_items( + runtime, current_state, issue_numbers + ), + run_command=automation.run_command, + summarize_output=automation.summarize_output, + list_changed_files=automation.list_changed_files, + get_default_branch=lambda: automation.get_default_branch(runtime), + find_open_pr_for_branch_status=lambda branch: automation.find_open_pr_for_branch_status(runtime, branch), + create_pull_request=lambda branch, base, issue_number: automation.create_pull_request( + runtime, branch, base, issue_number + ), + parse_issue_labels=automation.bot_parse_issue_labels, + normalize_lock_metadata=state_store.normalize_lock_metadata, + get_state_issue=lambda: state_store.get_state_issue(runtime), + clear_lock_metadata=lambda: lease_lock.clear_lock_metadata(runtime), + get_state_issue_snapshot=lambda: state_store.get_state_issue_snapshot(runtime), + conditional_patch_state_issue=lambda body, etag=None: state_store.conditional_patch_state_issue( + runtime, body, etag + ), + parse_lock_metadata_from_issue_body=state_store.parse_lock_metadata_from_issue_body, + render_state_issue_body=lambda current_state, lock_meta, base_body=None, *, preserve_state_block=False: state_store.render_state_issue_body( + current_state, + lock_meta, + base_body, + preserve_state_block=preserve_state_block, + ), + get_state_issue_html_url=lambda: lease_lock.get_state_issue_html_url(runtime), + get_lock_ref_display=lambda: lease_lock.get_lock_ref_display(runtime), + get_lock_ref_snapshot=lambda: lease_lock.get_lock_ref_snapshot(runtime), + build_lock_metadata=lambda *args, **kwargs: lease_lock.build_lock_metadata(runtime, *args, **kwargs), + create_lock_commit=lambda parent_sha, tree_sha, lock_meta: lease_lock.create_lock_commit( + runtime, parent_sha, tree_sha, lock_meta + ), + cas_update_lock_ref=lambda new_sha: lease_lock.cas_update_lock_ref(runtime, new_sha), + lock_is_currently_valid=lambda lock_meta, now=None: lease_lock.lock_is_currently_valid(runtime, lock_meta, now), + renew_state_issue_lease_lock=lambda context: lease_lock.renew_state_issue_lease_lock(runtime, context), + ensure_state_issue_lease_lock_fresh=lambda: lease_lock.ensure_state_issue_lease_lock_fresh(runtime), + acquire_state_issue_lease_lock=lambda: lease_lock.acquire_state_issue_lease_lock(runtime), + release_state_issue_lease_lock=lambda: lease_lock.release_state_issue_lease_lock(runtime), + get_active_lease_context=lambda: runtime.ACTIVE_LEASE_CONTEXT, + ) + + runtime = ReviewerBotRuntime( + requests=requests, + sys=sys, + random=random, + time=time, + state_store=state_store_services, + github=github_services, + locks=lock_services, + handlers=handlers, + adapters=adapters, + active_lease_context=active_lease_context, + ) + return runtime diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 1214f5627..393d3c788 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -342,6 +342,44 @@ def reconcile_active_review_entry( return f"ℹ️ Rectify checked PR #{issue_number}: {'; '.join(messages) or 'no reconciliation transitions applied'}.", True, False +def handle_rectify_command(bot, state: dict, issue_number: int, comment_author: str) -> tuple[str, bool, bool]: + review_data = bot.ensure_review_entry(state, issue_number) + current_reviewer = review_data.get("current_reviewer") if review_data else None + + is_current_reviewer = ( + isinstance(current_reviewer, str) + and current_reviewer.lower() == comment_author.lower() + ) + + triage_status = "denied" + if not is_current_reviewer: + triage_status = bot.get_user_permission_status(comment_author, "triage") + + if not is_current_reviewer and triage_status == "unavailable": + return ( + "❌ Unable to verify triage permissions right now; refusing to continue.", + False, + False, + ) + + if not is_current_reviewer and triage_status != "granted": + if current_reviewer: + return ( + f"❌ Only the assigned reviewer (@{current_reviewer}) or a maintainer with triage+ " + "permission can run `/rectify`.", + False, + False, + ) + return ( + "❌ Only maintainers with triage+ permission can run `/rectify` when no assigned " + "reviewer is tracked.", + False, + False, + ) + + return reconcile_active_review_entry(bot, state, issue_number) + + def _validate_deferred_comment_artifact(payload: dict) -> None: required = { "schema_version", diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 48d9b7d42..ff48a42db 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -380,3 +380,12 @@ def release_state_issue_lease_lock(self) -> bool: result = self.adapters.release_state_issue_lease_lock() self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() return result + + def process_pass_until_expirations(self, state: dict): + return self.adapters.process_pass_until_expirations(state) + + def sync_members_with_queue(self, state: dict): + return self.adapters.sync_members_with_queue(state) + + def sync_status_labels_for_items(self, state: dict, issue_numbers): + return self.adapters.sync_status_labels_for_items(state, issue_numbers) diff --git a/tests/conftest.py b/tests/conftest.py index 4121068be..e0b513409 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,18 @@ +from types import SimpleNamespace + import pytest -from scripts import reviewer_bot -from scripts.reviewer_bot_lib.config import AssignmentAttempt, GitHubApiResult +from tests.fixtures import reviewer_bot_recorders from tests.fixtures.reviewer_bot_env import ( build_test_lease_context, - reset_reviewer_bot_process_state, -) -from tests.fixtures.reviewer_bot_recorders import ( - record_comment_dicts, - record_status_label_ops, + clear_reviewer_bot_env, ) @pytest.fixture(autouse=True) def reset_reviewer_bot_process_state_fixture(): with pytest.MonkeyPatch().context() as monkeypatch: - reset_reviewer_bot_process_state(monkeypatch, reviewer_bot) + clear_reviewer_bot_env(monkeypatch) yield @@ -39,44 +36,10 @@ def tmp_deferred_path(tmp_path): @pytest.fixture -def stub_api(monkeypatch): - monkeypatch.setattr(reviewer_bot, "github_api", lambda *args, **kwargs: {}) - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: GitHubApiResult( - status_code=200, - payload={}, - headers={}, - text="", - ok=True, - ), - ) - monkeypatch.setattr(reviewer_bot, "add_reaction", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "post_comment", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "assign_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr( - reviewer_bot, - "request_reviewer_assignment", - lambda *args, **kwargs: AssignmentAttempt(success=True, status_code=201), - ) - monkeypatch.setattr(reviewer_bot, "unassign_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_pr_reviewer", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_assignee", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "get_repo_labels", lambda *args, **kwargs: {"a", "b"}) - monkeypatch.setattr(reviewer_bot, "add_label", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "add_label_with_status", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_label", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "remove_label_with_status", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "ensure_label_exists", lambda *args, **kwargs: True) - monkeypatch.setattr(reviewer_bot, "fetch_members", lambda *args, **kwargs: []) - - -@pytest.fixture -def captured_comments(monkeypatch): - return record_comment_dicts(reviewer_bot) +def captured_comments(): + return reviewer_bot_recorders.record_comment_dicts(SimpleNamespace()) @pytest.fixture -def captured_status_label_ops(monkeypatch): - return record_status_label_ops(reviewer_bot) +def captured_status_label_ops(): + return reviewer_bot_recorders.record_status_label_ops(SimpleNamespace()) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 2bddac37c..1fe7eda2d 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -5,56 +5,34 @@ pytestmark = pytest.mark.contract from scripts import reviewer_bot +from scripts.reviewer_bot_lib import lease_lock, review_state from scripts.reviewer_bot_lib.context import ( AppEventContextRuntime, AppExecutionRuntime, - GitHubTransportContext, - LeaseLockContext, ReviewerBotContext, - StateStoreContext, ) from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.reviewer_bot import make_state -def test_reviewer_bot_exports_runtime_modules(): - assert reviewer_bot.requests is not None - assert reviewer_bot.sys is not None - assert reviewer_bot.datetime is not None - assert reviewer_bot.timezone is not None - -def test_reviewer_bot_satisfies_runtime_context_protocol(): - assert isinstance(reviewer_bot, ReviewerBotContext) - -def test_reviewer_bot_satisfies_narrower_lock_and_state_protocols(): - assert isinstance(reviewer_bot, GitHubTransportContext) - assert isinstance(reviewer_bot, StateStoreContext) - assert isinstance(reviewer_bot, LeaseLockContext) - - -def test_reviewer_bot_satisfies_app_specific_runtime_protocols(): - assert isinstance(reviewer_bot, AppEventContextRuntime) - assert isinstance(reviewer_bot, AppExecutionRuntime) - -def test_render_lock_commit_message_uses_direct_json_import(): - rendered = reviewer_bot.render_lock_commit_message({"lock_state": "unlocked"}) - assert reviewer_bot.LOCK_COMMIT_MARKER in rendered +def test_runtime_bot_returns_concrete_runtime_object(): + runtime = reviewer_bot._runtime_bot() -def test_maybe_record_head_observation_repair_wrapper_exports_structured_result_type(): - hints = get_type_hints(reviewer_bot.maybe_record_head_observation_repair) + assert isinstance(runtime, ReviewerBotRuntime) + assert runtime.EVENT_INTENT_MUTATING == reviewer_bot._runtime_bot().EVENT_INTENT_MUTATING - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult -def test_runtime_context_protocol_exposes_structured_head_repair_contract(): - hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) +def test_runtime_object_satisfies_runtime_context_protocols(): + runtime = reviewer_bot._runtime_bot() - assert hints["return"] is reviewer_bot.lifecycle_module.HeadObservationRepairResult + assert isinstance(runtime, AppEventContextRuntime) + assert isinstance(runtime, AppExecutionRuntime) -def test_runtime_bot_returns_concrete_runtime_object(): - runtime = reviewer_bot._runtime_bot() - assert isinstance(runtime, ReviewerBotRuntime) - assert runtime.EVENT_INTENT_MUTATING == reviewer_bot.EVENT_INTENT_MUTATING +def test_render_lock_commit_message_uses_direct_json_import(): + rendered = lease_lock.render_lock_commit_message(reviewer_bot._runtime_bot(), {"lock_state": "unlocked"}) + assert rendered.startswith("reviewer-bot-lock-v1\n") def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): @@ -63,6 +41,7 @@ def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): assert isinstance(runtime, AppEventContextRuntime) assert isinstance(runtime, AppExecutionRuntime) + def test_build_event_context_returns_structured_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") @@ -71,11 +50,11 @@ def test_build_event_context_returns_structured_context(monkeypatch): context = reviewer_bot.build_event_context() - assert isinstance(context, reviewer_bot.EventContext) assert context.event_name == "workflow_run" assert context.workflow_run_event == "pull_request_review" assert context.issue_labels == ("coding guideline",) + def test_execute_run_returns_execution_result(monkeypatch): monkeypatch.setenv("EVENT_NAME", "pull_request_review") monkeypatch.setenv("EVENT_ACTION", "submitted") @@ -85,5 +64,23 @@ def test_execute_run_returns_execution_result(monkeypatch): result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) - assert isinstance(result, reviewer_bot.ExecutionResult) assert result.exit_code == 0 + + +def test_review_state_owner_exports_mutation_helper(): + hints = get_type_hints(review_state.ensure_review_entry) + + assert hints["return"] == dict | None + + +def test_runtime_head_repair_contract_is_runtime_scoped(): + hints = get_type_hints(ReviewerBotContext.maybe_record_head_observation_repair) + + assert hints["return"].__name__ == "HeadObservationRepairResult" + + +def test_runtime_review_state_adapter_mutates_active_reviews(): + state = make_state() + review = reviewer_bot._runtime_bot().ensure_review_entry(state, 42, create=True) + + assert review is state["active_reviews"]["42"] diff --git a/tests/contract/reviewer_bot/test_comment_application_contract.py b/tests/contract/reviewer_bot/test_comment_application_contract.py index 20ead9684..30164bb61 100644 --- a/tests/contract/reviewer_bot/test_comment_application_contract.py +++ b/tests/contract/reviewer_bot/test_comment_application_contract.py @@ -1,7 +1,7 @@ import pytest -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_application +from scripts.reviewer_bot_lib import comment_application, comment_routing, review_state +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -15,7 +15,7 @@ def test_comment_application_digest_is_stable_for_replay_identity(): def test_comment_application_records_contributor_freshness_from_typed_request(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None request = harness.request( issue_number=42, @@ -25,7 +25,7 @@ def test_comment_application_records_contributor_freshness_from_typed_request(mo comment_body="plain text", ) - changed = comment_application.record_conversation_freshness(reviewer_bot, state, request) + changed = comment_application.record_conversation_freshness(harness.runtime, state, request) assert changed is True assert review["contributor_comment"]["accepted"]["semantic_key"] == "issue_comment:100" @@ -34,7 +34,7 @@ def test_comment_application_records_contributor_freshness_from_typed_request(mo def test_comment_application_stores_pending_privileged_command_from_typed_request(monkeypatch): harness = CommentRoutingHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None request = harness.request( issue_number=42, @@ -43,7 +43,7 @@ def test_comment_application_stores_pending_privileged_command_from_typed_reques comment_author="dana", comment_body="@guidelines-bot /accept-no-fls-changes", ) - harness.runtime.parse_issue_labels = lambda: [reviewer_bot.FLS_AUDIT_LABEL] + harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" harness.runtime.post_comment = lambda issue_number, body: True @@ -51,8 +51,8 @@ def test_comment_application_stores_pending_privileged_command_from_typed_reques harness.runtime, state, request, - classify_comment_payload=reviewer_bot.comment_routing_module.classify_comment_payload, - classify_issue_comment_actor=reviewer_bot.comment_routing_module.classify_issue_comment_actor, + classify_comment_payload=comment_routing.classify_comment_payload, + classify_issue_comment_actor=comment_routing.classify_issue_comment_actor, ) assert changed is True diff --git a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py index ebf3ec28a..688aa737e 100644 --- a/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py +++ b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py @@ -1,10 +1,11 @@ from pathlib import Path +from types import SimpleNamespace import pytest pytestmark = pytest.mark.contract -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import reconcile @pytest.mark.parametrize( @@ -100,12 +101,12 @@ def test_deferred_workflow_identity_helpers_match_expected_contract( artifact_name, payload_name, ): - assert reviewer_bot.reconcile_module._expected_observer_identity(payload) == ( + assert reconcile._expected_observer_identity(payload) == ( workflow_name, workflow_file, ) - assert reviewer_bot.reconcile_module._artifact_expected_name(payload) == artifact_name - assert reviewer_bot.reconcile_module._artifact_expected_payload_name(payload) == payload_name + assert reconcile._artifact_expected_name(payload) == artifact_name + assert reconcile._artifact_expected_payload_name(payload) == payload_name def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatch(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Wrong Workflow") @@ -119,8 +120,10 @@ def test_validate_workflow_run_artifact_identity_rejects_triggering_name_mismatc "source_run_attempt": 1, } + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) + reconcile._validate_workflow_run_artifact_identity(bot, payload) def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") @@ -135,8 +138,10 @@ def test_validate_workflow_run_artifact_identity_rejects_run_attempt_mismatch(mo "source_run_attempt": 1, } + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + with pytest.raises(RuntimeError, match="run_attempt mismatch"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) + reconcile._validate_workflow_run_artifact_identity(bot, payload) def test_validate_workflow_run_artifact_identity_requires_successful_conclusion(monkeypatch): monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Comment Observer") @@ -150,5 +155,7 @@ def test_validate_workflow_run_artifact_identity_requires_successful_conclusion( "source_run_attempt": 1, } + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + with pytest.raises(RuntimeError, match="did not conclude successfully"): - reviewer_bot.reconcile_module._validate_workflow_run_artifact_identity(reviewer_bot, payload) + reconcile._validate_workflow_run_artifact_identity(bot, payload) diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py index e7a09f7a6..4397a9e4f 100644 --- a/tests/contract/reviewer_bot/test_workflow_files.py +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -1,10 +1,12 @@ from pathlib import Path + import pytest pytestmark = pytest.mark.contract import yaml + def test_issue_comment_direct_workflow_exports_issue_state(): workflow_text = Path(".github/workflows/reviewer-bot-issue-comment-direct.yml").read_text( encoding="utf-8" @@ -114,7 +116,8 @@ def test_workflow_summaries_and_runbook_references_exist(): assert "docs/reviewer-bot-review-freshness-operator-runbook.md" in reconcile def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): - from scripts import reviewer_bot + from scripts.reviewer_bot_lib import comment_routing + from tests.fixtures.comment_routing_harness import CommentRoutingHarness monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") monkeypatch.setenv("COMMENT_USER_TYPE", "User") @@ -129,16 +132,17 @@ def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_obs monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") monkeypatch.setenv("GITHUB_RUN_ID", "999") monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { + harness = CommentRoutingHarness(monkeypatch) + harness.github.add_api( + "GET", + "pulls/42", + { "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, "user": {"login": "PLeVasseur"}, }, ) - payload = reviewer_bot.build_pr_comment_observer_payload(42) + payload = comment_routing.build_pr_comment_observer_payload(harness.runtime, 42) assert payload["kind"] == "observer_noop" assert payload["reason"] == "trusted_direct_same_repo_human_comment" diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index fe6ccc4b2..4048118a8 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from scripts import reviewer_bot +from scripts.reviewer_bot_lib import app +from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_env import set_env_values @@ -11,7 +13,7 @@ @dataclass class MainRun: exit_code: int | None - context: reviewer_bot.EventContext | None = None + context: EventContext | None = None class AppHarness: @@ -49,14 +51,14 @@ def stub_sync_members(self, func) -> None: def stub_sync_status_labels(self, func) -> None: self.runtime.stub_sync_status_labels(func) - def stub_execute_run(self, result: reviewer_bot.ExecutionResult) -> MainRun: + def stub_execute_run(self, result: ExecutionResult) -> MainRun: captured = MainRun(exit_code=None) def fake_execute_run(bot, context): captured.context = context return result - self._monkeypatch.setattr(reviewer_bot.app_module, "execute_run", fake_execute_run) + self._monkeypatch.setattr(app, "execute_run", fake_execute_run) return captured def run_execute(self): diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index c934a3238..fb219de77 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -2,11 +2,13 @@ import subprocess -from scripts import reviewer_bot from scripts.reviewer_bot_lib import automation as automation_module from scripts.reviewer_bot_lib import commands as commands_module from scripts.reviewer_bot_lib import comment_routing as comment_routing_module from scripts.reviewer_bot_lib import event_inputs +from scripts.reviewer_bot_lib import maintenance as maintenance_module +from scripts.reviewer_bot_lib import reconcile as reconcile_module +from scripts.reviewer_bot_lib.config import AssignmentAttempt from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_env import set_env_values @@ -35,7 +37,6 @@ def __init__(self, monkeypatch): self._monkeypatch = monkeypatch self.runtime = FakeReviewerBotRuntime(monkeypatch) self.config = self.runtime.config - self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_comment_command( self, @@ -98,7 +99,7 @@ def stub_assignees(self, assignees): self.runtime.get_issue_assignees = lambda issue_number: assignees def stub_assignment(self, *, success: bool = True, status_code: int = 201): - self.runtime.request_reviewer_assignment = lambda issue_number, username: reviewer_bot.AssignmentAttempt( + self.runtime.request_reviewer_assignment = lambda issue_number, username: AssignmentAttempt( success=success, status_code=status_code ) @@ -152,6 +153,43 @@ def handle_pass(self, state: dict, issue_number: int, comment_author: str, reaso request=self.assignment_request(issue_number=issue_number), ) + def handle_pass_until(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None): + return commands_module.handle_pass_until_command( + self.runtime, + state, + issue_number, + comment_author, + return_date, + reason, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_release(self, state: dict, issue_number: int, comment_author: str, args=None): + return commands_module.handle_release_command( + self.runtime, + state, + issue_number, + comment_author, + args, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_assign_from_queue(self, state: dict, issue_number: int): + return commands_module.handle_assign_from_queue_command( + self.runtime, + state, + issue_number, + request=self.assignment_request(issue_number=issue_number), + ) + + def handle_rectify(self, state: dict, issue_number: int, comment_author: str): + return reconcile_module.handle_rectify_command( + self.runtime, + state, + issue_number, + comment_author, + ) + def handle_accept_no_fls_changes(self, issue_number: int, comment_author: str): return automation_module.handle_accept_no_fls_changes_command( self.runtime, @@ -167,3 +205,6 @@ def handle_comment_event(self, state: dict): event_inputs.build_comment_event_request(), event_inputs.build_pr_comment_trust_context(), ) + + def handle_manual_dispatch(self, state: dict): + return maintenance_module.handle_manual_dispatch(self.runtime, state) diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 5bd073d99..9b57ebc2e 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -1,6 +1,5 @@ from __future__ import annotations -from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing, event_inputs from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext @@ -16,7 +15,6 @@ def __init__(self, monkeypatch): self.github = RouteGitHubApi() self.runtime = FakeReviewerBotRuntime(monkeypatch, github=self.github) self.config = self.runtime.config - self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def wrapper_request(self, *, issue_number: int | None = None): return event_inputs.build_comment_event_request(issue_number=issue_number) diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index 637ba2231..f1ae9ad77 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -2,8 +2,7 @@ from dataclasses import dataclass -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_application, reconcile +from scripts.reviewer_bot_lib import comment_application, lifecycle, reconcile from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import pull_request_payload, review_payload @@ -126,7 +125,6 @@ def __post_init__(self) -> None: self.runtime = FakeReviewerBotRuntime(self.monkeypatch, github=self.github) self.config = self.runtime.config self.runtime.stub_deferred_payload(self.payload) - self.monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) self.set_trigger_from_payload(self.payload) def handle_workflow_run_event(self, state: dict) -> bool: @@ -270,14 +268,14 @@ def add_request_failure( ) def stub_head_repair(self, *, changed: bool = False, outcome: str = "unchanged") -> None: - self.runtime.maybe_record_head_observation_repair = lambda issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + self.runtime.maybe_record_head_observation_repair = lambda issue_number, review_data: lifecycle.HeadObservationRepairResult( changed=changed, outcome=outcome, ) def stub_review_rebuild(self, *, changed: bool = False) -> None: self.monkeypatch.setattr( - reviewer_bot.reconcile_module, + reconcile, "_record_review_rebuild", lambda bot, state_obj, issue_number, review_data: changed, ) diff --git a/tests/fixtures/reviewer_bot_env.py b/tests/fixtures/reviewer_bot_env.py index ed7e390e8..aeaff5407 100644 --- a/tests/fixtures/reviewer_bot_env.py +++ b/tests/fixtures/reviewer_bot_env.py @@ -42,9 +42,13 @@ def build_test_lease_context(): ) -def reset_reviewer_bot_process_state(monkeypatch, target_module) -> None: +def clear_reviewer_bot_env(monkeypatch) -> None: for name in CLEAR_REVIEWER_BOT_ENV_VARS: monkeypatch.delenv(name, raising=False) + + +def reset_reviewer_bot_process_state(monkeypatch, target_module) -> None: + clear_reviewer_bot_env(monkeypatch) monkeypatch.setattr(target_module, "ACTIVE_LEASE_CONTEXT", build_test_lease_context()) monkeypatch.setattr(target_module, "TOUCHED_ISSUE_NUMBERS", set()) monkeypatch.setattr(target_module, "_reviewer_board_project_metadata", None, raising=False) diff --git a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py index e856a8623..986fef4e8 100644 --- a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -3,7 +3,8 @@ pytestmark = pytest.mark.integration from builder import build_cli -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import automation +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL from tests.fixtures.commands_harness import CommandHarness @@ -13,7 +14,7 @@ def test_list_changed_files_ignores_untracked_bootstrap_noise(monkeypatch, tmp_p runner.when(["git", "diff", "--name-only"], stdout="") runner.when(["git", "diff", "--cached", "--name-only"], stdout="") - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == [] + assert automation.list_changed_files(tmp_path) == [] assert [command for command, _cwd, _check in runner.calls] == [ ["git", "diff", "--name-only"], ["git", "diff", "--cached", "--name-only"], @@ -25,25 +26,25 @@ def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): runner.when(["git", "diff", "--name-only"], stdout="README.md\nsrc/spec.lock\n") runner.when(["git", "diff", "--cached", "--name-only"], stdout="src/spec.lock\n") - assert reviewer_bot.automation_module.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] + assert automation.list_changed_files(tmp_path) == ["README.md", "src/spec.lock"] def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) harness.set_privileged_context( - labels=[reviewer_bot.FLS_AUDIT_LABEL], + labels=[FLS_AUDIT_LABEL], is_pull_request=False, target_repo_root=tmp_path, ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + harness.stub_permission("granted") observed = {"cwd": None} def fake_list_changed_files(repo_root): observed["cwd"] = repo_root return ["README.md"] - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + harness.runtime.list_changed_files = fake_list_changed_files - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice") assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) assert observed["cwd"] == tmp_path @@ -51,11 +52,11 @@ def fake_list_changed_files(repo_root): def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) harness.set_privileged_context( - labels=[reviewer_bot.FLS_AUDIT_LABEL], + labels=[FLS_AUDIT_LABEL], is_pull_request=False, target_repo_root=tmp_path, ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) + harness.stub_permission("granted") list_calls = {"count": 0} def fake_list_changed_files(repo_root): @@ -67,9 +68,9 @@ def fake_list_changed_files(repo_root): runner.when(["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"]) runner.when(["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"]) - monkeypatch.setattr(reviewer_bot, "list_changed_files", fake_list_changed_files) + harness.runtime.list_changed_files = fake_list_changed_files - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice") assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) assert list_calls["count"] == 2 @@ -81,12 +82,12 @@ def fake_list_changed_files(repo_root): def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) harness.set_privileged_context( - labels=[reviewer_bot.FLS_AUDIT_LABEL], + labels=[FLS_AUDIT_LABEL], is_pull_request=False, target_repo_root=tmp_path, ) - monkeypatch.setattr(reviewer_bot, "check_user_permission", lambda username, required_permission="triage": True) - monkeypatch.setattr(reviewer_bot, "list_changed_files", lambda repo_root: []) + harness.stub_permission("granted") + harness.runtime.list_changed_files = lambda repo_root: [] runner = harness.automation_runner() runner.when( ["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], @@ -94,7 +95,7 @@ def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, t stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", ) - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice") assert success is False assert "Audit command failed." in message diff --git a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py index 23f7dada7..8420c37a8 100644 --- a/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -1,6 +1,6 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import review_state from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -24,7 +24,7 @@ def test_execute_run_closed_issue_comment_cleanup_persists_removed_review_entry( ) initial_state = make_state() - review = reviewer_bot.ensure_review_entry(initial_state, 42, create=True) + review = review_state.ensure_review_entry(initial_state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" reloaded_state = make_state() diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py index 08042c67c..0dd3b9888 100644 --- a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -1,6 +1,6 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import review_state from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state, valid_reviewer_board_metadata @@ -97,7 +97,7 @@ def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): state = make_state() state["status_projection_epoch"] = "status_projection_v1" - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["assigned_at"] = "2026-03-20T12:34:56Z" diff --git a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py index c506adfc3..61a17cbd2 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -1,6 +1,8 @@ import pytest from scripts import reviewer_bot +from scripts.reviewer_bot_lib import lifecycle, maintenance, review_state +from scripts.reviewer_bot_lib.config import GitHubApiResult from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -10,7 +12,7 @@ def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_stat harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" @@ -26,14 +28,14 @@ def fake_sweep(bot, current): harness.stub_load_state(lambda *, fail_on_unavailable=False: state) harness.stub_pass_until(lambda current: (current, [])) harness.stub_sync_members(lambda current: (current, [])) - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", fake_sweep) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", fake_sweep) + monkeypatch.setattr(maintenance, "check_overdue_reviews", lambda bot, current: []) harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} - monkeypatch.setattr(reviewer_bot.review_state_module, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data, *, reviews=None: False) + monkeypatch.setattr(review_state, "repair_missing_reviewer_review_state", lambda bot, issue_number, review_data, *, reviews=None: False) monkeypatch.setattr( - reviewer_bot.maintenance_module, + maintenance, "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult( changed=False, outcome="unchanged", ), @@ -50,7 +52,7 @@ def test_execute_run_schedule_reviewer_review_activity_only_repair_still_saves_s harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" @@ -73,7 +75,7 @@ def test_execute_run_schedule_reviewer_review_activity_only_repair_still_saves_s def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **kwargs): if endpoint == "pulls/42": - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( 200, {"state": "open", "head": {"sha": "head-1"}}, {}, @@ -84,7 +86,7 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k None, ) if endpoint.startswith("pulls/42/reviews"): - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( 200, [ { @@ -108,14 +110,14 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k harness.stub_load_state(lambda *, fail_on_unavailable=False: state) harness.stub_pass_until(lambda current: (current, [])) harness.stub_sync_members(lambda current: (current, [])) - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False) - monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, current: []) + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", lambda bot, current: False) + monkeypatch.setattr(maintenance, "check_overdue_reviews", lambda bot, current: []) harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []} harness.runtime.github_api_request = fake_github_api_request monkeypatch.setattr( - reviewer_bot.maintenance_module, + maintenance, "maybe_record_head_observation_repair", - lambda bot, issue_number, review_data: reviewer_bot.lifecycle_module.HeadObservationRepairResult( + lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult( changed=False, outcome="unchanged", ), diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py index 7f59cfe99..a44a2f77e 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_repairs.py +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -5,6 +5,8 @@ pytestmark = pytest.mark.integration from scripts import reviewer_bot +from scripts.reviewer_bot_lib import maintenance, review_state +from scripts.reviewer_bot_lib.config import STATUS_PROJECTION_EPOCH from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -14,7 +16,7 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() state["status_projection_epoch"] = "status_projection_v1" - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" synced_issue_numbers = [] @@ -33,14 +35,14 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re assert result.exit_code == 0 assert synced_issue_numbers == [42, 99] - assert saved_epochs[-1] == reviewer_bot.STATUS_PROJECTION_EPOCH + assert saved_epochs[-1] == STATUS_PROJECTION_EPOCH def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync_failure(monkeypatch): harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() state["status_projection_epoch"] = "status_projection_v1" - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" saved_epochs = [] @@ -57,12 +59,12 @@ def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) assert result.exit_code == 0 - assert all(epoch != reviewer_bot.STATUS_PROJECTION_EPOCH for epoch in saved_epochs) + assert all(epoch != STATUS_PROJECTION_EPOCH for epoch in saved_epochs) def test_execute_run_records_repair_needed_when_projection_fails(monkeypatch, tmp_path): harness = AppHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.set_event( @@ -108,7 +110,7 @@ def test_schedule_overdue_check_does_not_repeat_warning_after_stale_review_repai harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "iglesias" review["assigned_at"] = "2026-02-26T04:58:03Z" @@ -123,7 +125,7 @@ def fake_load_state(*args, **kwargs): return state def fake_sweep(bot, current): - bot.review_state_module.record_reviewer_activity( + review_state.record_reviewer_activity( current["active_reviews"]["42"], "2026-03-18T01:09:05Z", ) @@ -133,7 +135,7 @@ def fake_sweep(bot, current): harness.stub_load_state(lambda *, fail_on_unavailable=False: fake_load_state()) harness.stub_pass_until(lambda current: (current, [])) harness.stub_sync_members(lambda current: (current, [])) - monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", fake_sweep) + monkeypatch.setattr(maintenance, "sweep_deferred_gaps", fake_sweep) harness.stub_handler("handle_scheduled_check", lambda current: False) harness.runtime.post_comment = lambda issue_number, body: posted_comments.append((issue_number, body)) or True harness.stub_save_state( diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py index d000e5e00..93e53d479 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -5,6 +5,7 @@ pytestmark = pytest.mark.integration from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_routing, review_state from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -19,7 +20,7 @@ def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(t ) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "bob" review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} @@ -63,7 +64,10 @@ def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(t harness.stub_sync_members(lambda current: (current, [])) harness.stub_handler( "handle_workflow_run_event", - lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review:11") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review:11", None) or True, + lambda current: harness.runtime.collect_touched_item(42) + or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review:11") + or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review:11", None) + or True, ) harness.stub_save_state( lambda current: save_snapshots.append( @@ -94,7 +98,7 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st ) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} @@ -115,7 +119,7 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st "comment_id": 210, "comment_class": "command_only", "has_non_command_text": False, - "source_body_digest": reviewer_bot.comment_routing_module._digest_body("@guidelines-bot /queue"), + "source_body_digest": comment_routing._digest_body("@guidelines-bot /queue"), "source_created_at": "2026-03-17T10:00:00Z", "actor_login": "bob", } @@ -138,7 +142,10 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st harness.stub_sync_members(lambda current: (current, [])) harness.stub_handler( "handle_workflow_run_event", - lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("issue_comment:210") or current["active_reviews"]["42"]["deferred_gaps"].pop("issue_comment:210", None) or True, + lambda current: harness.runtime.collect_touched_item(42) + or current["active_reviews"]["42"]["reconciled_source_events"].append("issue_comment:210") + or current["active_reviews"]["42"]["deferred_gaps"].pop("issue_comment:210", None) + or True, ) harness.stub_save_state( lambda current: save_snapshots.append( @@ -168,7 +175,7 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon ) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["deferred_gaps"]["pull_request_review_comment:310"] = {"reason": "artifact_missing"} @@ -189,7 +196,7 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon "comment_id": 310, "comment_class": "plain_text", "has_non_command_text": True, - "source_body_digest": reviewer_bot.comment_routing_module._digest_body("review comment body"), + "source_body_digest": comment_routing._digest_body("review comment body"), "source_created_at": "2026-03-17T10:00:00Z", "actor_login": "alice", } @@ -213,7 +220,10 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon harness.stub_handler( "handle_workflow_run_event", - lambda current: reviewer_bot.collect_touched_item(42) or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review_comment:310") or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review_comment:310", None) or True, + lambda current: harness.runtime.collect_touched_item(42) + or current["active_reviews"]["42"]["reconciled_source_events"].append("pull_request_review_comment:310") + or current["active_reviews"]["42"]["deferred_gaps"].pop("pull_request_review_comment:310", None) + or True, ) harness.stub_save_state( lambda current: save_snapshots.append( diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py index a153780ee..ee538a665 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_paths.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -1,6 +1,6 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import LeaseContext from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -53,7 +53,7 @@ def test_execute_run_workflow_run_reconcile_acquires_lock(monkeypatch): def fake_acquire(): acquire_called["value"] = True - return reviewer_bot.LeaseContext( + return LeaseContext( lock_token="token", lock_owner_run_id="run", lock_owner_workflow="workflow", @@ -69,9 +69,10 @@ def fake_acquire(): harness.stub_sync_members(lambda state: (state, [])) harness.stub_handler("handle_workflow_run_event", lambda state: False) - harness.run_execute() + result = harness.run_execute() assert acquire_called["value"] is True + assert result.exit_code == 0 def test_execute_run_workflow_run_review_comment_reconcile_acquires_lock(monkeypatch): harness = AppHarness(monkeypatch) @@ -81,7 +82,7 @@ def test_execute_run_workflow_run_review_comment_reconcile_acquires_lock(monkeyp def fake_acquire(): acquire_called["value"] = True - return reviewer_bot.LeaseContext( + return LeaseContext( lock_token="token", lock_owner_run_id="run", lock_owner_workflow="workflow", @@ -97,6 +98,7 @@ def fake_acquire(): harness.stub_sync_members(lambda state: (state, [])) harness.stub_handler("handle_workflow_run_event", lambda state: False) - harness.run_execute() + result = harness.run_execute() assert acquire_called["value"] is True + assert result.exit_code == 0 diff --git a/tests/integration/reviewer_bot/test_lifecycle_integration.py b/tests/integration/reviewer_bot/test_lifecycle_integration.py index 2724d29d4..c355d2daa 100644 --- a/tests/integration/reviewer_bot/test_lifecycle_integration.py +++ b/tests/integration/reviewer_bot/test_lifecycle_integration.py @@ -1,64 +1,46 @@ -import json import pytest +from scripts.reviewer_bot_lib import review_state +from tests.fixtures.reconcile_harness import ReconcileHarness, review_submitted_payload +from tests.fixtures.reviewer_bot import make_state + pytestmark = pytest.mark.integration -from scripts import reviewer_bot -from tests.fixtures.reviewer_bot import make_state -def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(tmp_path, monkeypatch): +def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(monkeypatch): state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" review["active_cycle_started_at"] = "2026-03-17T09:00:00Z" review["transition_warning_sent"] = "2026-03-18T00:00:00Z" review["transition_notice_sent_at"] = "2026-03-25T00:00:00Z" - payload_path = tmp_path / "deferred-review.json" - payload_path.write_text( - json.dumps( - { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } + harness = ReconcileHarness( + monkeypatch, + review_submitted_payload( + pr_number=42, + review_id=11, + source_event_key="pull_request_review:11", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, ), - encoding="utf-8", ) - monkeypatch.setenv("DEFERRED_CONTEXT_PATH", str(payload_path)) - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_NAME", "Reviewer Bot PR Review Submitted Observer") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ID", "500") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_ATTEMPT", "2") - monkeypatch.setenv("WORKFLOW_RUN_TRIGGERING_CONCLUSION", "success") - monkeypatch.setattr( - reviewer_bot, - "github_api", - lambda method, endpoint, data=None: { - "pulls/42": {"head": {"sha": "head-2"}, "user": {"login": "dana"}, "labels": []}, - "pulls/42/reviews/11": { - "id": 11, - "submitted_at": "2026-03-17T10:00:00Z", - "state": "COMMENTED", - "commit_id": "head-1", - "user": {"login": "alice"}, - }, - }.get(endpoint), + harness.add_pull_request(pr_number=42, head_sha="head-2", author="dana") + harness.add_review( + pr_number=42, + review_id=11, + submitted_at="2026-03-17T10:00:00Z", + state="COMMENTED", + commit_id="head-1", + author="alice", ) - monkeypatch.setattr( - reviewer_bot, - "get_pull_request_reviews", - lambda issue_number: [ + harness.add_reviews_page( + pr_number=42, + reviews=[ { "id": 11, "submitted_at": "2026-03-17T10:00:00Z", @@ -69,6 +51,6 @@ def test_workflow_run_review_submission_clears_warning_and_transition_notice_mar ], ) - assert reviewer_bot.handle_workflow_run_event(state) is True + assert harness.run(state) is True assert review["transition_warning_sent"] is None assert review["transition_notice_sent_at"] is None diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 070593dbd..475a472cd 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -1,6 +1,6 @@ import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import comment_application, reconcile from tests.fixtures.reconcile_harness import ( ReconcileHarness, issue_comment_payload, @@ -113,7 +113,7 @@ def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations( author_type="User", author_association="MEMBER", ) - monkeypatch.setattr(reviewer_bot.comment_application_module, "apply_comment_command", lambda *args, **kwargs: False) + monkeypatch.setattr(comment_application, "apply_comment_command", lambda *args, **kwargs: False) assert harness.run(state) is True assert "issue_comment:210" in review["reconciled_source_events"] @@ -408,7 +408,7 @@ def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguou author_association="MEMBER", ) monkeypatch.setattr( - reviewer_bot.reconcile_module, + reconcile, "classify_comment_payload", lambda bot, body: { "comment_class": "command_only", @@ -421,7 +421,7 @@ def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguou ) command_calls = [] monkeypatch.setattr( - reviewer_bot.comment_application_module, + comment_application, "apply_comment_command", lambda *args, **kwargs: command_calls.append("called") or True, ) @@ -502,7 +502,7 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" claim_contexts = [] monkeypatch.setattr( - reviewer_bot.comment_application_module, + comment_application, "apply_comment_command", lambda bot, state_obj, request, classified, classify_issue_comment_actor=None: claim_contexts.append( { @@ -588,7 +588,7 @@ def test_deferred_comment_reconcile_fails_closed_when_comment_classification_dri author_association="CONTRIBUTOR", ) monkeypatch.setattr( - reviewer_bot.reconcile_module, + reconcile, "classify_comment_payload", lambda bot, body: { "comment_class": "command_plus_text", diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index 11f5899ff..9ffc296ba 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -1,8 +1,13 @@ import pytest -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing +from scripts.reviewer_bot_lib import ( + automation, + comment_application, + guidance, + review_state, +) +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL from tests.fixtures.commands_harness import CommandHarness from tests.fixtures.comment_routing_harness import CommentRoutingHarness from tests.fixtures.reviewer_bot import make_state @@ -11,7 +16,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.set_comment_command( @@ -23,15 +28,13 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat ) harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] harness.runtime.add_label = lambda issue_number, label: True - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda *args, **kwargs: pytest.fail("status sync should run only from app orchestration after save"), + harness.runtime.sync_status_labels_for_items = lambda *args, **kwargs: pytest.fail( + "status sync should run only from app orchestration after save" ) harness.runtime.add_reaction = lambda *args, **kwargs: True posted = harness.capture_posted_comments() - assert reviewer_bot.handle_comment_event(state) is True + assert harness.handle_comment_event(state) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" assert review["current_cycle_completion"]["completed"] is True assert posted == [(42, "✅ Added label `sign-off: create pr`")] @@ -40,7 +43,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.set_comment_command( @@ -60,15 +63,13 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) } harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] harness.runtime.add_label = lambda issue_number, label: True - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda *args, **kwargs: pytest.fail("status sync should not run for PR sign-off label command"), + harness.runtime.sync_status_labels_for_items = lambda *args, **kwargs: pytest.fail( + "status sync should not run for PR sign-off label command" ) harness.runtime.add_reaction = lambda *args, **kwargs: True harness.runtime.post_comment = lambda *args, **kwargs: True - assert reviewer_bot.handle_comment_event(state) is False + assert harness.handle_comment_event(state) is False assert review["review_completion_source"] is None @@ -79,7 +80,7 @@ def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkey harness.runtime.github_api = lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): - reviewer_bot.create_pull_request("feature-branch", "main", 42) + automation.create_pull_request(harness.runtime, "feature-branch", "main", 42) assert called["post"] == 0 @@ -90,7 +91,7 @@ def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + response, success = harness.handle_assign(state, 42, "@felix91gr") assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -106,11 +107,11 @@ def test_assign_command_posts_pr_guidance_on_success(monkeypatch): posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = reviewer_bot.handle_assign_command(state, 42, "@felix91gr") + response, success = harness.handle_assign(state, 42, "@felix91gr") assert success is True assert response == "✅ @felix91gr has been assigned as reviewer." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] def test_claim_command_posts_pr_guidance_on_success(monkeypatch): @@ -123,11 +124,11 @@ def test_claim_command_posts_pr_guidance_on_success(monkeypatch): posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = reviewer_bot.handle_claim_command(state, 42, "felix91gr") + response, success = harness.handle_claim(state, 42, "felix91gr") assert success is True assert response == "✅ @felix91gr has claimed this review." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): @@ -137,7 +138,7 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): {"github": "alice", "name": "Alice"}, {"github": "felix91gr", "name": "Félix Fischer"}, ] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) @@ -147,11 +148,11 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + response, success = harness.handle_pass(state, 42, "alice", None) assert success is True assert "@felix91gr is now assigned as the reviewer." in response - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] def test_assign_from_queue_posts_guidance_only_once(monkeypatch): @@ -164,19 +165,19 @@ def test_assign_from_queue_posts_guidance_only_once(monkeypatch): posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + response, success = harness.handle_assign_from_queue(state, 42) assert success is True assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." - assert posted == [reviewer_bot.get_pr_guidance("felix91gr", "PLeVasseur")] + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): harness = CommandHarness(monkeypatch) - harness.set_privileged_context(labels=[reviewer_bot.FLS_AUDIT_LABEL], is_pull_request=False) + harness.set_privileged_context(labels=[FLS_AUDIT_LABEL], is_pull_request=False) harness.stub_permission("unavailable") - message, success = reviewer_bot.handle_accept_no_fls_changes_command(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice") assert success is False assert "Unable to verify triage permissions right now" in message @@ -185,11 +186,11 @@ def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unava def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_pass_command(state, 42, "alice", None) + response, success = harness.handle_pass(state, 42, "alice", None) assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -199,18 +200,12 @@ def test_away_command_fails_closed_when_assignees_unavailable(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_pass_until_command( - state, - 42, - "alice", - "2099-01-01", - None, - ) + response, success = harness.handle_pass_until(state, 42, "alice", "2099-01-01", None) assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -222,7 +217,7 @@ def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch): state["queue"] = [{"github": "alice", "name": "Alice"}] harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_claim_command(state, 42, "alice") + response, success = harness.handle_claim(state, 42, "alice") assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -233,7 +228,7 @@ def test_release_command_fails_closed_when_permission_unavailable(monkeypatch): state = make_state() harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" - response, success = reviewer_bot.handle_release_command(state, 42, "alice", ["@bob"]) + response, success = harness.handle_release(state, 42, "alice", ["@bob"]) assert success is False assert "Unable to verify triage permissions right now" in response @@ -244,7 +239,7 @@ def test_release_command_fails_closed_when_assignees_unavailable(monkeypatch): state = make_state() harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_release_command(state, 42, "alice") + response, success = harness.handle_release(state, 42, "alice") assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -256,7 +251,7 @@ def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monke state["queue"] = [{"github": "alice", "name": "Alice"}] harness.runtime.get_issue_assignees = lambda issue_number: None - response, success = reviewer_bot.handle_assign_from_queue_command(state, 42) + response, success = harness.handle_assign_from_queue(state, 42) assert success is False assert "Unable to determine current assignees/reviewers" in response @@ -264,10 +259,11 @@ def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monke def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): state = make_state() - monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + harness = CommandHarness(monkeypatch) + harness.runtime.ensure_review_entry = lambda current, issue_number, create=False: None + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" - message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + message, success, changed = harness.handle_rectify(state, 42, "alice") assert success is False assert changed is False @@ -276,10 +272,11 @@ def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): def test_handle_rectify_command_reports_permission_denied(monkeypatch): state = make_state() - monkeypatch.setattr(reviewer_bot, "ensure_review_entry", lambda current, issue_number: None) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "denied") + harness = CommandHarness(monkeypatch) + harness.runtime.ensure_review_entry = lambda current, issue_number, create=False: None + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "denied" - message, success, changed = reviewer_bot.handle_rectify_command(state, 42, "alice") + message, success, changed = harness.handle_rectify(state, 42, "alice") assert success is False assert changed is False @@ -288,8 +285,8 @@ def test_handle_rectify_command_reports_permission_denied(monkeypatch): def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): harness = CommentRoutingHarness(monkeypatch) - monkeypatch.setattr(reviewer_bot, "parse_issue_labels", lambda: [reviewer_bot.FLS_AUDIT_LABEL]) - monkeypatch.setattr(reviewer_bot, "get_user_permission_status", lambda username, required_permission="triage": "unavailable") + harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" request = harness.request( issue_number=42, is_pull_request=False, @@ -297,8 +294,8 @@ def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavail comment_body="@guidelines-bot /accept-no-fls-changes", ) - ok, metadata = comment_routing._validate_accept_no_fls_changes_handoff( - reviewer_bot, + ok, metadata = comment_application.validate_accept_no_fls_changes_handoff( + harness.runtime, request, ) @@ -309,7 +306,7 @@ def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavail def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() - review = reviewer_bot.ensure_review_entry(state, 42, create=True) + review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["pending_privileged_commands"] = { "issue_comment:100": { @@ -321,10 +318,10 @@ def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privilege } } harness.set_manual_dispatch(source_event_key="issue_comment:100") - harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": reviewer_bot.FLS_AUDIT_LABEL}]} + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" - assert reviewer_bot.handle_manual_dispatch(state) is True + assert harness.handle_manual_dispatch(state) is True pending = review["pending_privileged_commands"]["issue_comment:100"] assert pending["status"] == "failed_closed" assert pending["result"] == "live_permission_unavailable" diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 5a2aaa489..e280bf21c 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -1,7 +1,7 @@ -import pytest - from types import SimpleNamespace +import pytest + from scripts.reviewer_bot_lib import comment_application, comment_routing, review_state from scripts.reviewer_bot_lib.context import CommentEventRequest from tests.fixtures.comment_routing_harness import CommentRoutingHarness @@ -75,7 +75,14 @@ def test_classify_issue_comment_actor(monkeypatch, env, expected): def test_classify_comment_payload_distinguishes_command_plus_text(): - payload = comment_routing.classify_comment_payload(SimpleNamespace(BOT_MENTION="@guidelines-bot"), "hello\n@guidelines-bot /queue") + payload = comment_routing.classify_comment_payload( + SimpleNamespace( + BOT_MENTION="@guidelines-bot", + strip_code_blocks=lambda body: body, + parse_command=lambda body: ("queue", []), + ), + "hello\n@guidelines-bot /queue", + ) assert payload["comment_class"] == "command_plus_text" assert payload["has_non_command_text"] is True diff --git a/tests/unit/reviewer_bot/test_lease_lock.py b/tests/unit/reviewer_bot/test_lease_lock.py index a6961f197..3dab33b15 100644 --- a/tests/unit/reviewer_bot/test_lease_lock.py +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -1,11 +1,47 @@ +import sys +from types import SimpleNamespace + import pytest -from scripts import reviewer_bot +from scripts.reviewer_bot_lib import lease_lock, state_store +from scripts.reviewer_bot_lib.config import GitHubApiResult, LeaseContext + + +def _lease_bot(**overrides): + bot = SimpleNamespace( + ACTIVE_LEASE_CONTEXT=None, + LOCK_REF_NAME="heads/reviewer-bot-state-lock", + LOCK_REF_BOOTSTRAP_BRANCH="main", + LOCK_API_RETRY_LIMIT=3, + LOCK_RETRY_BASE_SECONDS=0, + LOCK_MAX_WAIT_SECONDS=60, + sys=sys, + github_api_request=lambda *args, **kwargs: None, + get_state_issue_snapshot=lambda: SimpleNamespace(html_url="https://example.com/issues/314"), + normalize_lock_metadata=state_store.normalize_lock_metadata, + parse_iso8601_timestamp=state_store.parse_iso8601_timestamp, + ) + for key, value in overrides.items(): + setattr(bot, key, value) + bot.clear_lock_metadata = lambda: lease_lock.clear_lock_metadata(bot) + bot.get_state_issue_html_url = lambda: lease_lock.get_state_issue_html_url(bot) + bot.get_lock_ref_display = lambda: lease_lock.get_lock_ref_display(bot) + bot.get_lock_ref_snapshot = lambda: lease_lock.get_lock_ref_snapshot(bot) + bot.build_lock_metadata = lambda *args: lease_lock.build_lock_metadata(bot, *args) + bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: lease_lock.create_lock_commit( + bot, parent_sha, tree_sha, lock_meta + ) + bot.cas_update_lock_ref = lambda new_sha: lease_lock.cas_update_lock_ref(bot, new_sha) + bot.lock_is_currently_valid = lambda lock_meta, now=None: lease_lock.lock_is_currently_valid( + bot, lock_meta, now + ) + return bot def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): + bot = _lease_bot() monkeypatch.setattr( - reviewer_bot.lease_lock_module, + lease_lock, "get_lock_owner_context", lambda: ("local-run", "reviewer-bot", "reviewer-bot"), ) @@ -29,33 +65,22 @@ def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): ] ) - monkeypatch.setattr( - reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() - ) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr( - reviewer_bot, - "create_lock_commit", - lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( - 201, {"sha": "commit-1"}, {}, "", True - ), - ) - monkeypatch.setattr( - reviewer_bot, - "cas_update_lock_ref", - lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) + bot.get_lock_ref_snapshot = lambda: next(snapshots) + bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True ) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) - context = reviewer_bot.acquire_state_issue_lease_lock() + context = lease_lock.acquire_state_issue_lease_lock(bot) assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + assert bot.ACTIVE_LEASE_CONTEXT is context def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): + bot = _lease_bot() snapshots = iter( [ ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), @@ -63,33 +88,22 @@ def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): ] ) - monkeypatch.setattr( - reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() - ) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr( - reviewer_bot, - "create_lock_commit", - lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( - 201, {"sha": "commit-1"}, {}, "", True - ), + monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) + bot.get_lock_ref_snapshot = lambda: next(snapshots) + bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True ) - monkeypatch.setattr( - reviewer_bot, - "cas_update_lock_ref", - lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), - ) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) with pytest.raises(RuntimeError, match="unexpected lock state"): - reviewer_bot.acquire_state_issue_lease_lock() + lease_lock.acquire_state_issue_lease_lock(bot) def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): + bot = _lease_bot() monkeypatch.setattr( - reviewer_bot.lease_lock_module, + lease_lock, "get_lock_owner_context", lambda: ("local-run", "reviewer-bot", "reviewer-bot"), ) @@ -112,35 +126,24 @@ def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypa ] ) - monkeypatch.setattr( - reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() - ) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr( - reviewer_bot, - "create_lock_commit", - lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( - 201, {"sha": "commit-1"}, {}, "", True - ), - ) - monkeypatch.setattr( - reviewer_bot, - "cas_update_lock_ref", - lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) + bot.get_lock_ref_snapshot = lambda: next(snapshots) + bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( + 201, {"sha": "commit-1"}, {}, "", True ) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) - context = reviewer_bot.acquire_state_issue_lease_lock() + context = lease_lock.acquire_state_issue_lease_lock(bot) assert context.lock_token == "token-123" - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is context + assert bot.ACTIVE_LEASE_CONTEXT is context def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): + bot = _lease_bot() monkeypatch.setattr( - reviewer_bot.lease_lock_module, + lease_lock, "get_lock_owner_context", lambda: ("local-run", "reviewer-bot", "reviewer-bot"), ) @@ -161,20 +164,16 @@ def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypat ] ) - monkeypatch.setattr( - reviewer_bot.lease_lock_module.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})() - ) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr(reviewer_bot, "get_state_issue_html_url", lambda: "https://example.com/issues/314") - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", None) + monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) + monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) + bot.get_lock_ref_snapshot = lambda: next(snapshots) with pytest.raises(RuntimeError, match="owner metadata drifted"): - reviewer_bot.acquire_state_issue_lease_lock() + lease_lock.acquire_state_issue_lease_lock(bot) def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): - context = reviewer_bot.LeaseContext( + context = LeaseContext( lock_token="token-123", lock_owner_run_id="run", lock_owner_workflow="workflow", @@ -183,6 +182,7 @@ def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): lock_ref="refs/heads/reviewer-bot-state-lock", lock_expires_at="2999-01-01T00:00:00+00:00", ) + bot = _lease_bot(ACTIVE_LEASE_CONTEXT=context) snapshots = iter( [ ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), @@ -190,28 +190,19 @@ def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): ] ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr(reviewer_bot.lease_lock_module.time, "sleep", lambda seconds: None) - monkeypatch.setattr(reviewer_bot, "get_lock_ref_snapshot", lambda: next(snapshots)) - monkeypatch.setattr( - reviewer_bot, - "create_lock_commit", - lambda parent_sha, tree_sha, lock_meta: reviewer_bot.GitHubApiResult( - 201, {"sha": "commit-2"}, {}, "", True - ), - ) - monkeypatch.setattr( - reviewer_bot, - "cas_update_lock_ref", - lambda new_sha: reviewer_bot.GitHubApiResult(200, {}, {}, "", True), + monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) + bot.get_lock_ref_snapshot = lambda: next(snapshots) + bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( + 201, {"sha": "commit-2"}, {}, "", True ) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) - assert reviewer_bot.release_state_issue_lease_lock() is True - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + assert lease_lock.release_state_issue_lease_lock(bot) is True + assert bot.ACTIVE_LEASE_CONTEXT is None def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): - context = reviewer_bot.LeaseContext( + context = LeaseContext( lock_token="token-123", lock_owner_run_id="run", lock_owner_workflow="workflow", @@ -220,15 +211,15 @@ def test_release_lock_fails_closed_on_conflicting_token(monkeypatch): lock_ref="refs/heads/reviewer-bot-state-lock", lock_expires_at="2999-01-01T00:00:00+00:00", ) - monkeypatch.setattr(reviewer_bot, "ACTIVE_LEASE_CONTEXT", context) - monkeypatch.setattr( - reviewer_bot, - "get_lock_ref_snapshot", - lambda: ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), + bot = _lease_bot(ACTIVE_LEASE_CONTEXT=context) + bot.get_lock_ref_snapshot = lambda: ( + "new-ref", + "tree", + {"lock_state": "locked", "lock_token": "other-token"}, ) - assert reviewer_bot.release_state_issue_lease_lock() is False - assert reviewer_bot.ACTIVE_LEASE_CONTEXT is None + assert lease_lock.release_state_issue_lease_lock(bot) is False + assert bot.ACTIVE_LEASE_CONTEXT is None def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): @@ -237,7 +228,7 @@ def test_ensure_lock_ref_exists_uses_retry_aware_reads(monkeypatch): def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed.append((endpoint, kwargs.get("retry_policy"))) if endpoint == "git/ref/heads/reviewer-bot-state-lock" and len(observed) == 1: - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=404, payload={"message": "missing"}, headers={}, @@ -248,7 +239,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) if endpoint == "git/ref/heads/reviewer-bot-state-lock": - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=200, payload={"object": {"sha": "base-sha"}}, headers={}, @@ -259,7 +250,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) if endpoint == "git/ref/heads/main": - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=200, payload={"object": {"sha": "base-sha"}}, headers={}, @@ -270,7 +261,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) if endpoint == "git/refs": - return reviewer_bot.GitHubApiResult( + return GitHubApiResult( status_code=201, payload={"ref": "refs/heads/reviewer-bot-state-lock"}, headers={}, @@ -282,11 +273,9 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): ) raise AssertionError(endpoint) - monkeypatch.setattr(reviewer_bot, "github_api_request", fake_request) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + bot = _lease_bot(github_api_request=fake_request) - assert reviewer_bot.ensure_lock_ref_exists() == "base-sha" + assert lease_lock.ensure_lock_ref_exists(bot) == "base-sha" assert observed[0] == ("git/ref/heads/reviewer-bot-state-lock", "idempotent_read") assert observed[1] == ("git/ref/heads/main", "idempotent_read") @@ -294,28 +283,19 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): responses = iter( [ - reviewer_bot.GitHubApiResult( - 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None - ), - reviewer_bot.GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), + GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), ] ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr(reviewer_bot, "LOCK_REF_NAME", "heads/reviewer-bot-state-lock") - monkeypatch.setattr(reviewer_bot, "LOCK_REF_BOOTSTRAP_BRANCH", "main") + bot = _lease_bot(github_api_request=lambda *args, **kwargs: next(responses)) with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): - reviewer_bot.ensure_lock_ref_exists() + lease_lock.ensure_lock_ref_exists(bot) def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda method, endpoint, data=None, extra_headers=None, **kwargs: reviewer_bot.GitHubApiResult( + bot = _lease_bot( + github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( status_code=200, payload={"message": "missing tree"}, headers={}, @@ -324,67 +304,63 @@ def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatc failure_kind=None, retry_attempts=0, transport_error=None, - ), + ) ) + monkeypatch.setattr(lease_lock, "ensure_lock_ref_exists", lambda current_bot: "ref-sha") with pytest.raises(RuntimeError, match="missing tree SHA"): - reviewer_bot.get_lock_ref_snapshot() + lease_lock.get_lock_ref_snapshot(bot) def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): - context = reviewer_bot.LeaseContext( + context = LeaseContext( lock_token="expected-token", lock_owner_run_id="run-1", lock_owner_workflow="wf", lock_owner_job="job", state_issue_url="https://example.com/state/1", ) - monkeypatch.setattr( - reviewer_bot, - "get_lock_ref_snapshot", - lambda: ("ref-sha", "tree-sha", {"lock_token": "different-token", "lock_state": "locked"}), + bot = _lease_bot() + bot.get_lock_ref_snapshot = lambda: ( + "ref-sha", + "tree-sha", + {"lock_token": "different-token", "lock_state": "locked"}, ) - assert reviewer_bot.renew_state_issue_lease_lock(context) is False + assert lease_lock.renew_state_issue_lease_lock(bot, context) is False def test_ensure_lock_ref_exists_fails_closed_when_lock_ref_read_remains_unavailable(monkeypatch): - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + bot = _lease_bot( + github_api_request=lambda *args, **kwargs: GitHubApiResult( 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), + ) ) with pytest.raises(RuntimeError, match="Failed to read reviewer-bot lock ref"): - reviewer_bot.ensure_lock_ref_exists() + lease_lock.ensure_lock_ref_exists(bot) def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): responses = iter( [ - reviewer_bot.GitHubApiResult( - 404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None - ), - reviewer_bot.GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), + GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), ] ) - monkeypatch.setattr(reviewer_bot, "github_api_request", lambda *args, **kwargs: next(responses)) + bot = _lease_bot(github_api_request=lambda *args, **kwargs: next(responses)) with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): - reviewer_bot.ensure_lock_ref_exists() + lease_lock.ensure_lock_ref_exists(bot) def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): - monkeypatch.setattr(reviewer_bot.lease_lock_module, "ensure_lock_ref_exists", lambda bot: "ref-sha") - monkeypatch.setattr( - reviewer_bot, - "github_api_request", - lambda *args, **kwargs: reviewer_bot.GitHubApiResult( + bot = _lease_bot( + github_api_request=lambda *args, **kwargs: GitHubApiResult( 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ), + ) ) + monkeypatch.setattr(lease_lock, "ensure_lock_ref_exists", lambda current_bot: "ref-sha") with pytest.raises(RuntimeError, match="Failed to read lock commit"): - reviewer_bot.get_lock_ref_snapshot() + lease_lock.get_lock_ref_snapshot(bot) From b67295fa9ea6aa206e60ca5c063ec15467cc0d98 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 04:23:20 +0900 Subject: [PATCH 103/209] test(reviewer-bot): harden authority contracts --- .../test_fake_runtime_contract.py | 34 ++++++++++- .../test_review_state_contract.py | 56 ++++++++++++++++++- .../test_support_layer_ownership.py | 28 +++++++++- 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 9f4227b36..3d94199a8 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -1,7 +1,7 @@ import pytest from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi pytestmark = pytest.mark.contract @@ -16,6 +16,29 @@ def test_fake_runtime_config_writes_mirror_env(monkeypatch): assert "_module" not in vars(runtime) +def test_fake_runtime_exposes_explicit_service_fields_and_no_omnibus_service_container(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert runtime.config is not None + assert runtime.outputs is not None + assert runtime.deferred_payloads is not None + assert runtime.state_store is not None + assert runtime.github is not None + assert runtime.locks is not None + assert runtime.handlers is not None + assert hasattr(runtime, "services") is False + assert hasattr(runtime, "components") is False + + +def test_fake_runtime_module_hints_are_explicitly_limited(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + module_hints = sorted(name for name in vars(FakeReviewerBotRuntime) if name.endswith("_module")) + + assert module_hints == ["review_state_module", "reviews_module"] + assert runtime.review_state_module is not None + assert runtime.reviews_module is not None + + def test_fake_runtime_output_sink_records_writes(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) @@ -89,6 +112,15 @@ def test_fake_runtime_uses_explicit_public_service_fields(monkeypatch): assert runtime.locks is not None +def test_fake_runtime_mutable_review_state_gateways_delegate_to_review_state_owner(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert runtime.ensure_review_entry.__func__.__module__ == FakeReviewerBotRuntime.__module__ + assert runtime.set_current_reviewer.__func__.__module__ == FakeReviewerBotRuntime.__module__ + assert runtime.update_reviewer_activity.__func__.__module__ == FakeReviewerBotRuntime.__module__ + assert runtime.mark_review_complete.__func__.__module__ == FakeReviewerBotRuntime.__module__ + + def test_fake_runtime_rejects_unknown_handler_names(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) diff --git a/tests/contract/reviewer_bot/test_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py index 29861c943..cdcd42914 100644 --- a/tests/contract/reviewer_bot/test_review_state_contract.py +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -1,11 +1,20 @@ +from pathlib import Path + import pytest -from scripts.reviewer_bot_lib import review_state +from scripts.reviewer_bot_lib import review_state, reviews from tests.fixtures.reviewer_bot import make_state pytestmark = pytest.mark.contract +ROOT = Path(__file__).resolve().parents[3] + + +def _read(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + def test_ensure_review_entry_initializes_tracked_review_shape(): state = make_state() @@ -61,3 +70,48 @@ def test_list_open_tracked_review_items_returns_only_assigned_entries(): state["active_reviews"]["42"]["current_reviewer"] = "alice" assert review_state.list_open_tracked_review_items(state) == [42] + + +def test_review_state_module_exposes_named_mutation_surface(): + for name in [ + "ensure_review_entry", + "accept_channel_event", + "record_reviewer_activity", + "record_transition_notice_sent", + "set_current_reviewer", + "update_reviewer_activity", + "mark_review_complete", + "get_current_cycle_boundary", + ]: + assert hasattr(review_state, name) + + +def test_production_modules_do_not_import_mutable_review_state_api_from_reviews_module(): + review_state_text = _read("scripts/reviewer_bot_lib/review_state.py") + runtime_text = _read("scripts/reviewer_bot_lib/runtime.py") + bootstrap_text = _read("scripts/reviewer_bot_lib/bootstrap_runtime.py") + + for name in [ + "ensure_review_entry", + "accept_channel_event", + "record_reviewer_activity", + "record_transition_notice_sent", + "set_current_reviewer", + "update_reviewer_activity", + "mark_review_complete", + "get_current_cycle_boundary", + ]: + assert f"def {name}(" in review_state_text + assert f"from .reviews import {name}" not in runtime_text + assert f"reviews.{name}(" not in runtime_text + assert f"reviews.{name}(" not in bootstrap_text + + +def test_reviews_module_still_contains_known_transitional_mutation_helpers_only_as_debt_markers(): + for name in [ + "ensure_review_entry", + "set_current_reviewer", + "update_reviewer_activity", + "mark_review_complete", + ]: + assert hasattr(reviews, name) diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py index 0838ee72d..dca358c21 100644 --- a/tests/contract/reviewer_bot/test_support_layer_ownership.py +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -2,7 +2,12 @@ import pytest -from tests.fixtures import reviewer_bot_env, reviewer_bot_recorders +from tests.fixtures import ( + github, + reviewer_bot_env, + reviewer_bot_fakes, + reviewer_bot_recorders, +) pytestmark = pytest.mark.contract @@ -19,6 +24,19 @@ def test_support_layer_has_owned_env_and_recorder_modules(): assert reviewer_bot_recorders is not None +def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes(): + assert reviewer_bot_fakes.RouteGitHubApi is github.RouteGitHubApi + assert reviewer_bot_fakes.github_result is github.github_result + + +def test_github_fixture_module_is_limited_to_transport_alias_and_low_level_response_helper(): + github_text = _read("tests/fixtures/github.py") + + assert "from .reviewer_bot_fakes import RouteGitHubApi, github_result" in github_text + assert "class FakeGitHubResponse:" in github_text + assert github.__all__ == ["FakeGitHubResponse", "RouteGitHubApi", "github_result"] + + def test_harnesses_do_not_define_local_config_bags_or_deferred_payload_loaders(): for relative_path in [ "tests/fixtures/app_harness.py", @@ -55,3 +73,11 @@ def test_no_second_fake_runtime_or_transport_home_exists(): assert "commands_harness.py" in fixture_files assert "comment_routing_harness.py" in fixture_files assert "reconcile_harness.py" in fixture_files + + +def test_support_layer_ownership_contract_targets_current_authority_hotspots_only(): + text = _read("tests/contract/reviewer_bot/test_support_layer_ownership.py") + + assert "RouteGitHubApi" in text + assert "github_result" in text + assert "FakeGitHubResponse" in text From 85eae87e00717ebca663fca4baa894d5c86715e8 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 04:30:47 +0900 Subject: [PATCH 104/209] test(reviewer-bot): cut over transport fake imports --- .../test_fake_github_transport_contract.py | 2 +- .../test_support_layer_ownership.py | 20 ++++++++++++++----- tests/fixtures/github.py | 9 +-------- .../reviewer_bot/test_fixture_contracts.py | 2 +- tests/unit/reviewer_bot/test_overdue.py | 2 +- tests/unit/reviewer_bot/test_project_board.py | 2 +- .../reviewer_bot/test_reconcile_artifacts.py | 2 +- .../reviewer_bot/test_reviews_api_contract.py | 2 +- .../reviewer_bot/test_reviews_live_fetch.py | 2 +- tests/unit/reviewer_bot/test_sweeper_logic.py | 2 +- 10 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_github_transport_contract.py b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py index fdab84965..498a09fbe 100644 --- a/tests/contract/reviewer_bot/test_fake_github_transport_contract.py +++ b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py @@ -1,6 +1,6 @@ import pytest -from tests.fixtures.github import RouteGitHubApi, github_result +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result pytestmark = pytest.mark.contract diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py index dca358c21..05c2fa3ba 100644 --- a/tests/contract/reviewer_bot/test_support_layer_ownership.py +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -25,16 +25,17 @@ def test_support_layer_has_owned_env_and_recorder_modules(): def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes(): - assert reviewer_bot_fakes.RouteGitHubApi is github.RouteGitHubApi - assert reviewer_bot_fakes.github_result is github.github_result + assert reviewer_bot_fakes.RouteGitHubApi is not None + assert reviewer_bot_fakes.github_result is not None -def test_github_fixture_module_is_limited_to_transport_alias_and_low_level_response_helper(): +def test_github_fixture_module_is_limited_to_low_level_response_helper_only(): github_text = _read("tests/fixtures/github.py") - assert "from .reviewer_bot_fakes import RouteGitHubApi, github_result" in github_text assert "class FakeGitHubResponse:" in github_text - assert github.__all__ == ["FakeGitHubResponse", "RouteGitHubApi", "github_result"] + assert "RouteGitHubApi" not in github_text + assert "github_result" not in github_text + assert github.__all__ == ["FakeGitHubResponse"] def test_harnesses_do_not_define_local_config_bags_or_deferred_payload_loaders(): @@ -81,3 +82,12 @@ def test_support_layer_ownership_contract_targets_current_authority_hotspots_onl assert "RouteGitHubApi" in text assert "github_result" in text assert "FakeGitHubResponse" in text + + +def test_transport_fakes_are_not_imported_from_github_helper_module_anywhere_in_tests(): + for path in ROOT.glob("tests/**/*.py"): + if path.name == "github.py" or path.name == "test_support_layer_ownership.py": + continue + text = path.read_text(encoding="utf-8") + assert "from tests.fixtures.github import RouteGitHubApi" not in text + assert "from tests.fixtures.github import github_result" not in text diff --git a/tests/fixtures/github.py b/tests/fixtures/github.py index def7c01b6..2145375ca 100644 --- a/tests/fixtures/github.py +++ b/tests/fixtures/github.py @@ -1,6 +1,3 @@ -from .reviewer_bot_fakes import RouteGitHubApi, github_result - - class FakeGitHubResponse: def __init__(self, status_code, payload=None, text="", headers=None): self.status_code = status_code @@ -14,8 +11,4 @@ def json(self): raise self._payload return self._payload -__all__ = [ - "FakeGitHubResponse", - "RouteGitHubApi", - "github_result", -] +__all__ = ["FakeGitHubResponse"] diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index 3514ad0e3..f4ef5b295 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -1,4 +1,4 @@ -from tests.fixtures.github import RouteGitHubApi, github_result +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result def test_shared_github_fixture_exports_core_transport_helpers(): diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py index a75a80afa..6cc230dae 100644 --- a/tests/unit/reviewer_bot/test_overdue.py +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -2,7 +2,6 @@ from scripts.reviewer_bot_lib import maintenance, review_state, reviews from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import ( accept_contributor_comment, accept_contributor_revision, @@ -14,6 +13,7 @@ pull_request_payload, review_payload, ) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi def _runtime(monkeypatch, routes=None): diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 6236139d0..0dbf1019e 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -6,7 +6,6 @@ STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL, ) from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import ( accept_reviewer_comment, accept_reviewer_review, @@ -17,6 +16,7 @@ review_payload, valid_reviewer_board_metadata, ) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi def _runtime(monkeypatch, routes=None): diff --git a/tests/unit/reviewer_bot/test_reconcile_artifacts.py b/tests/unit/reviewer_bot/test_reconcile_artifacts.py index c41d3232e..3354e6830 100644 --- a/tests/unit/reviewer_bot/test_reconcile_artifacts.py +++ b/tests/unit/reviewer_bot/test_reconcile_artifacts.py @@ -1,8 +1,8 @@ from scripts.reviewer_bot_lib import reconcile, review_state from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reconcile_harness import review_comment_payload from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi def test_review_comment_artifact_identity_validation(monkeypatch): diff --git a/tests/unit/reviewer_bot/test_reviews_api_contract.py b/tests/unit/reviewer_bot/test_reviews_api_contract.py index d83452763..75786f46a 100644 --- a/tests/unit/reviewer_bot/test_reviews_api_contract.py +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -2,7 +2,7 @@ from scripts.reviewer_bot_lib import reviews from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi, github_result +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypatch): diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index d9c176f9d..9e54ef7c6 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -5,7 +5,6 @@ STATUS_AWAITING_WRITE_APPROVAL_LABEL, ) from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi, github_result from tests.fixtures.reviewer_bot import ( accept_contributor_revision, accept_reviewer_comment, @@ -17,6 +16,7 @@ pull_request_payload, review_payload, ) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result def _runtime(monkeypatch, routes=None): diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 9d6f9ed41..5e2edfc52 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -2,8 +2,8 @@ from scripts.reviewer_bot_lib import review_state, sweeper from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import RouteGitHubApi from tests.fixtures.reviewer_bot import make_state +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi from tests.fixtures.reviewer_bot_sweeper_builders import ( artifact_payload, issue_comment_event, From 2a2ff06111856be2163ff3ec20ba752c7e41e3d8 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 04:32:57 +0900 Subject: [PATCH 105/209] test(reviewer-bot): isolate low-level HTTP fixtures --- .../test_support_layer_ownership.py | 25 +++++++++++++------ .../fixtures/{github.py => http_responses.py} | 1 + tests/unit/reviewer_bot/test_github_api.py | 2 +- .../reviewer_bot/test_sweeper_downloads.py | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) rename tests/fixtures/{github.py => http_responses.py} (99%) diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py index 05c2fa3ba..b0a6bf6dc 100644 --- a/tests/contract/reviewer_bot/test_support_layer_ownership.py +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -3,7 +3,7 @@ import pytest from tests.fixtures import ( - github, + http_responses, reviewer_bot_env, reviewer_bot_fakes, reviewer_bot_recorders, @@ -29,13 +29,13 @@ def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes(): assert reviewer_bot_fakes.github_result is not None -def test_github_fixture_module_is_limited_to_low_level_response_helper_only(): - github_text = _read("tests/fixtures/github.py") +def test_low_level_http_response_helper_has_dedicated_home(): + response_text = _read("tests/fixtures/http_responses.py") - assert "class FakeGitHubResponse:" in github_text - assert "RouteGitHubApi" not in github_text - assert "github_result" not in github_text - assert github.__all__ == ["FakeGitHubResponse"] + assert "class FakeGitHubResponse:" in response_text + assert "RouteGitHubApi" not in response_text + assert "github_result" not in response_text + assert http_responses.__all__ == ["FakeGitHubResponse"] def test_harnesses_do_not_define_local_config_bags_or_deferred_payload_loaders(): @@ -74,6 +74,7 @@ def test_no_second_fake_runtime_or_transport_home_exists(): assert "commands_harness.py" in fixture_files assert "comment_routing_harness.py" in fixture_files assert "reconcile_harness.py" in fixture_files + assert "http_responses.py" in fixture_files def test_support_layer_ownership_contract_targets_current_authority_hotspots_only(): @@ -86,8 +87,16 @@ def test_support_layer_ownership_contract_targets_current_authority_hotspots_onl def test_transport_fakes_are_not_imported_from_github_helper_module_anywhere_in_tests(): for path in ROOT.glob("tests/**/*.py"): - if path.name == "github.py" or path.name == "test_support_layer_ownership.py": + if path.name == "test_support_layer_ownership.py": continue text = path.read_text(encoding="utf-8") assert "from tests.fixtures.github import RouteGitHubApi" not in text assert "from tests.fixtures.github import github_result" not in text + + +def test_fake_github_response_uses_dedicated_low_level_helper_home(): + for path in ROOT.glob("tests/**/*.py"): + if path.name == "test_support_layer_ownership.py": + continue + text = path.read_text(encoding="utf-8") + assert "from tests.fixtures.github import FakeGitHubResponse" not in text diff --git a/tests/fixtures/github.py b/tests/fixtures/http_responses.py similarity index 99% rename from tests/fixtures/github.py rename to tests/fixtures/http_responses.py index 2145375ca..37516840a 100644 --- a/tests/fixtures/github.py +++ b/tests/fixtures/http_responses.py @@ -11,4 +11,5 @@ def json(self): raise self._payload return self._payload + __all__ = ["FakeGitHubResponse"] diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 9b9d1a0a6..974df3092 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -4,7 +4,7 @@ from scripts.reviewer_bot_lib import automation, github_api from scripts.reviewer_bot_lib.config import LOCK_API_RETRY_LIMIT, GitHubApiResult -from tests.fixtures.github import FakeGitHubResponse +from tests.fixtures.http_responses import FakeGitHubResponse def _bot(**overrides): diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index 393741716..7fe3ff694 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -1,6 +1,6 @@ from scripts.reviewer_bot_lib import sweeper from tests.fixtures.fake_runtime import FakeReviewerBotRuntime -from tests.fixtures.github import FakeGitHubResponse +from tests.fixtures.http_responses import FakeGitHubResponse from tests.fixtures.reviewer_bot import make_zip_payload From 75d851c5501442c106d6674b97cf281dd84bab36 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:04:59 +0900 Subject: [PATCH 106/209] refactor(reviewer-bot): tighten fake runtime shape --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 37 +++++++++++++ scripts/reviewer_bot_lib/context.py | 21 +++++++ scripts/reviewer_bot_lib/runtime.py | 55 +++++++++++++++++++ .../test_fake_runtime_contract.py | 12 ++-- tests/fixtures/app_harness.py | 6 +- tests/fixtures/commands_harness.py | 2 +- tests/fixtures/fake_runtime.py | 50 +++++++++-------- tests/unit/reviewer_bot/test_overdue.py | 2 +- tests/unit/reviewer_bot/test_project_board.py | 2 +- .../reviewer_bot/test_reconcile_artifacts.py | 2 +- .../reviewer_bot/test_reviews_api_contract.py | 12 ++-- .../reviewer_bot/test_reviews_live_fetch.py | 2 +- tests/unit/reviewer_bot/test_sweeper_logic.py | 14 ++--- 13 files changed, 168 insertions(+), 49 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index fad4e6dbd..c6c4410b4 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -8,6 +8,7 @@ automation, commands, comment_routing, + config, events, github_api, lease_lock, @@ -96,6 +97,42 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> maybe_record_head_observation_repair=lambda issue_number, review_data: lifecycle.maybe_record_head_observation_repair( runtime, issue_number, review_data ), + handle_transition_notice=lambda current_state, issue_number, reviewer: lifecycle.handle_transition_notice( + runtime, current_state, issue_number, reviewer + ), + handle_pass_command=lambda current_state, issue_number, comment_author, reason: commands.handle_pass_command( + runtime, current_state, issue_number, comment_author, reason + ), + handle_pass_until_command=lambda current_state, issue_number, comment_author, return_date, reason: commands.handle_pass_until_command( + runtime, current_state, issue_number, comment_author, return_date, reason + ), + handle_label_command=lambda current_state, issue_number, label_string: commands.handle_label_command( + runtime, current_state, issue_number, label_string + ), + handle_sync_members_command=lambda current_state: commands.handle_sync_members_command( + runtime, current_state + ), + handle_queue_command=lambda current_state: commands.handle_queue_command(runtime, current_state), + handle_commands_command=lambda: commands.handle_commands_command(runtime), + handle_claim_command=lambda current_state, issue_number, comment_author: commands.handle_claim_command( + runtime, current_state, issue_number, comment_author + ), + handle_release_command=lambda current_state, issue_number, comment_author, args=None: commands.handle_release_command( + runtime, current_state, issue_number, comment_author, args + ), + handle_rectify_command=lambda current_state, issue_number, comment_author: reconcile.handle_rectify_command( + runtime, current_state, issue_number, comment_author + ), + handle_assign_command=lambda current_state, issue_number, username: commands.handle_assign_command( + runtime, current_state, issue_number, username + ), + handle_assign_from_queue_command=lambda current_state, issue_number: commands.handle_assign_from_queue_command( + runtime, current_state, issue_number + ), + handle_accept_no_fls_changes_command=lambda issue_number, comment_author: automation.handle_accept_no_fls_changes_command( + runtime, issue_number, comment_author + ), + get_commands_help=config.get_commands_help, ensure_review_entry=lambda current_state, issue_number, create=False: review_state.ensure_review_entry( current_state, issue_number, create=create ), diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index b0b6e332a..350809ec2 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -318,3 +318,24 @@ def compute_reviewer_response_state( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> dict[str, object]: ... + def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: ... + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): ... + def handle_pass_until_command( + self, + state: dict, + issue_number: int, + comment_author: str, + return_date: str, + reason: str | None, + ): ... + def handle_label_command(self, state: dict, issue_number: int, label_string: str): ... + def handle_sync_members_command(self, state: dict): ... + def handle_queue_command(self, state: dict): ... + def handle_commands_command(self): ... + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): ... + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): ... + def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): ... + def handle_assign_command(self, state: dict, issue_number: int, username: str): ... + def handle_assign_from_queue_command(self, state: dict, issue_number: int): ... + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): ... + def get_commands_help(self) -> str: ... diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index ff48a42db..d3fea6eaa 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -253,6 +253,61 @@ def get_pull_request_reviews(self, issue_number: int): def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): return self.adapters.maybe_record_head_observation_repair(issue_number, review_data) + def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: + return self.adapters.handle_transition_notice(state, issue_number, reviewer) + + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): + return self.adapters.handle_pass_command(state, issue_number, comment_author, reason) + + def handle_pass_until_command( + self, + state: dict, + issue_number: int, + comment_author: str, + return_date: str, + reason: str | None, + ): + return self.adapters.handle_pass_until_command( + state, + issue_number, + comment_author, + return_date, + reason, + ) + + def handle_label_command(self, state: dict, issue_number: int, label_string: str): + return self.adapters.handle_label_command(state, issue_number, label_string) + + def handle_sync_members_command(self, state: dict): + return self.adapters.handle_sync_members_command(state) + + def handle_queue_command(self, state: dict): + return self.adapters.handle_queue_command(state) + + def handle_commands_command(self): + return self.adapters.handle_commands_command() + + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): + return self.adapters.handle_claim_command(state, issue_number, comment_author) + + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): + return self.adapters.handle_release_command(state, issue_number, comment_author, args) + + def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): + return self.adapters.handle_rectify_command(state, issue_number, comment_author) + + def handle_assign_command(self, state: dict, issue_number: int, username: str): + return self.adapters.handle_assign_command(state, issue_number, username) + + def handle_assign_from_queue_command(self, state: dict, issue_number: int): + return self.adapters.handle_assign_from_queue_command(state, issue_number) + + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): + return self.adapters.handle_accept_no_fls_changes_command(issue_number, comment_author) + + def get_commands_help(self) -> str: + return self.adapters.get_commands_help() + def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): return self.adapters.ensure_review_entry(state, issue_number, create=create) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 3d94199a8..65def654f 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -26,17 +26,18 @@ def test_fake_runtime_exposes_explicit_service_fields_and_no_omnibus_service_con assert runtime.github is not None assert runtime.locks is not None assert runtime.handlers is not None + assert runtime.touch_tracker is not None assert hasattr(runtime, "services") is False assert hasattr(runtime, "components") is False -def test_fake_runtime_module_hints_are_explicitly_limited(monkeypatch): +def test_fake_runtime_exposes_no_class_level_module_authority_hints(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) module_hints = sorted(name for name in vars(FakeReviewerBotRuntime) if name.endswith("_module")) - assert module_hints == ["review_state_module", "reviews_module"] - assert runtime.review_state_module is not None - assert runtime.reviews_module is not None + assert module_hints == [] + assert hasattr(runtime, "review_state_module") is False + assert hasattr(runtime, "reviews_module") is False def test_fake_runtime_output_sink_records_writes(monkeypatch): @@ -110,6 +111,7 @@ def test_fake_runtime_uses_explicit_public_service_fields(monkeypatch): assert runtime.state_store is not None assert runtime.github is not None assert runtime.locks is not None + assert runtime.touch_tracker is not None def test_fake_runtime_mutable_review_state_gateways_delegate_to_review_state_owner(monkeypatch): @@ -125,7 +127,7 @@ def test_fake_runtime_rejects_unknown_handler_names(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) with pytest.raises(AssertionError, match="Unsupported runtime handler override"): - runtime.stub_handler("handle_everything", lambda state: False) + runtime.handlers.stub("handle_everything", lambda state: False) def test_fake_runtime_github_transport_delegates_to_shared_route_fake(monkeypatch): diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index 4048118a8..f44d06702 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -31,16 +31,16 @@ def set_state_sequence(self, *states: dict) -> None: self.runtime.stub_state_sequence(*states) def stub_load_state(self, func) -> None: - self.runtime.stub_load_state(func) + self.runtime.state_store.stub_load(func) def stub_save_state(self, func) -> None: - self.runtime.stub_save_state(func) + self.runtime.state_store.stub_save(func) def stub_lock(self, *, acquire=None, release=None, refresh=None) -> None: self.runtime.stub_lock(acquire=acquire, release=release, refresh=refresh) def stub_handler(self, name: str, func) -> None: - self.runtime.stub_handler(name, func) + self.runtime.handlers.stub(name, func) def stub_pass_until(self, func) -> None: self.runtime.stub_pass_until(func) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index fb219de77..8c07412fc 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -107,7 +107,7 @@ def stub_permission(self, status: str) -> None: self.runtime.get_user_permission_status = lambda username, required_permission="triage": status def stub_handler(self, name: str, func) -> None: - self.runtime.stub_handler(name, func) + self.runtime.handlers.stub(name, func) def automation_runner(self) -> AutomationRunner: runner = AutomationRunner() diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 705262b8e..5c867a953 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -11,6 +11,7 @@ from scripts.reviewer_bot_lib import automation as automation_module from scripts.reviewer_bot_lib import commands as commands_module from scripts.reviewer_bot_lib import comment_routing as comment_routing_module +from scripts.reviewer_bot_lib import config as config_module from scripts.reviewer_bot_lib import github_api as github_api_module from scripts.reviewer_bot_lib import lifecycle as lifecycle_module from scripts.reviewer_bot_lib import maintenance as maintenance_module @@ -171,6 +172,20 @@ def call(self, name: str, state: dict) -> bool: return self._handlers[name](state) +class TouchTrackerStub: + def __init__(self): + self._touched: list[int] = [] + + def collect(self, issue_number: int | None) -> None: + if isinstance(issue_number, int) and issue_number not in self._touched: + self._touched.append(issue_number) + + def drain(self) -> list[int]: + items = list(self._touched) + self._touched.clear() + return items + + class FakeReviewerBotRuntime: BOT_NAME = BOT_NAME BOT_MENTION = BOT_MENTION @@ -184,8 +199,6 @@ class FakeReviewerBotRuntime: DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS = DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS GitHubApiResult = GitHubApiResult AssignmentAttempt = AssignmentAttempt - reviews_module = reviews_module - review_state_module = review_state_module EVENT_INTENT_MUTATING = EVENT_INTENT_MUTATING EVENT_INTENT_NON_MUTATING_DEFER = EVENT_INTENT_NON_MUTATING_DEFER EVENT_INTENT_NON_MUTATING_READONLY = EVENT_INTENT_NON_MUTATING_READONLY @@ -224,7 +237,7 @@ def __init__(self, monkeypatch, *, github=None): "handle_workflow_run_event": lambda state: reconcile_module.handle_workflow_run_event(self, state), } ) - self._touched_items: list[int] = [] + self.touch_tracker = TouchTrackerStub() self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False @@ -451,6 +464,12 @@ def handle_assign_from_queue_command(self, state: dict, issue_number: int): def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author) + def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): + return reconcile_module.handle_rectify_command(self, state, issue_number, comment_author) + + def get_commands_help(self) -> str: + return config_module.get_commands_help() + def parse_issue_labels(self) -> list[str]: return automation_module.bot_parse_issue_labels() @@ -515,29 +534,14 @@ def github_api_request(self, method: str, endpoint: str, data=None, extra_header ) def collect_touched_item(self, issue_number: int) -> None: - if issue_number not in self._touched_items: - self._touched_items.append(issue_number) + self.touch_tracker.collect(issue_number) def drain_touched_items(self) -> list[int]: - items = list(self._touched_items) - self._touched_items.clear() - return items - - def stub_load_state(self, func: Callable[..., dict]) -> None: - self.state_store.stub_load(func) - - def stub_save_state(self, func: Callable[[dict], bool]) -> None: - self.state_store.stub_save(func) + return self.touch_tracker.drain() def stub_lock(self, *, acquire=None, release=None, refresh=None) -> None: self.locks.stub(acquire=acquire, release=release, refresh=refresh) - def stub_github(self, github) -> None: - self.github.stub(github) - - def stub_handler(self, name: str, func: Callable[[dict], bool]) -> None: - self.handlers.stub(name, func) - def stub_deferred_payload(self, payload: dict) -> None: self.deferred_payloads.set_payload(payload) @@ -551,21 +555,21 @@ def fake_load_state(*, fail_on_unavailable: bool = False): return state_queue[0] return state_queue.pop(0) - self.stub_load_state(fake_load_state) + self.state_store.stub_load(fake_load_state) def stub_state_unavailable(self, message: str = "state unavailable") -> None: def fake_load_state(*, fail_on_unavailable: bool = False): assert fail_on_unavailable is True raise RuntimeError(message) - self.stub_load_state(fake_load_state) + self.state_store.stub_load(fake_load_state) def record_saves(self, snapshots: list) -> None: def fake_save_state(state: dict) -> bool: snapshots.append(json.loads(json.dumps(state))) return True - self.stub_save_state(fake_save_state) + self.state_store.stub_save(fake_save_state) def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: self._process_pass_until = func diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py index 6cc230dae..cfc052559 100644 --- a/tests/unit/reviewer_bot/test_overdue.py +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -21,7 +21,7 @@ def _runtime(monkeypatch, routes=None): runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) runtime.get_user_permission_status = lambda username, required_permission="push": "granted" if routes is not None: - runtime.stub_github(routes) + runtime.github.stub(routes) return runtime diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 0dbf1019e..bc5d0fc4f 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -22,7 +22,7 @@ def _runtime(monkeypatch, routes=None): runtime = FakeReviewerBotRuntime(monkeypatch) if routes is not None: - runtime.stub_github(routes) + runtime.github.stub(routes) return runtime diff --git a/tests/unit/reviewer_bot/test_reconcile_artifacts.py b/tests/unit/reviewer_bot/test_reconcile_artifacts.py index 3354e6830..6c2218362 100644 --- a/tests/unit/reviewer_bot/test_reconcile_artifacts.py +++ b/tests/unit/reviewer_bot/test_reconcile_artifacts.py @@ -49,6 +49,6 @@ def test_review_comment_artifact_identity_validation(monkeypatch): }, ) ) - runtime.stub_github(routes) + runtime.github.stub(routes) assert reconcile.handle_workflow_run_event(runtime, state) is True diff --git a/tests/unit/reviewer_bot/test_reviews_api_contract.py b/tests/unit/reviewer_bot/test_reviews_api_contract.py index 75786f46a..a070d521b 100644 --- a/tests/unit/reviewer_bot/test_reviews_api_contract.py +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -12,7 +12,7 @@ def test_list_open_items_with_status_labels_fails_closed_on_unavailable(monkeypa "issues?state=open&labels=status%3A%20awaiting%20contributor%20response&per_page=100&page=1", result=github_result(502, {"message": "bad gateway"}, retry_attempts=1), ) - runtime.stub_github(routes) + runtime.github.stub(routes) with pytest.raises(RuntimeError, match="server_error"): reviews.list_open_items_with_status_labels(runtime) @@ -35,7 +35,7 @@ def test_get_pull_request_reviews_result_paginates(monkeypatch): payload=[{"id": 100}], ) ) - runtime.stub_github(routes) + runtime.github.stub(routes) result = reviews.get_pull_request_reviews_result(runtime, 42) @@ -50,7 +50,7 @@ def test_get_pull_request_reviews_result_uses_fallback_loader_after_system_exit( "pulls/42/reviews?per_page=100&page=1", [{"id": 10}], ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [{"id": 10}] result = reviews.get_pull_request_reviews_result(runtime, 42) @@ -66,7 +66,7 @@ def test_get_pull_request_reviews_result_reports_invalid_payload(monkeypatch): status_code=200, payload={"not": "a list"}, ) - runtime.stub_github(routes) + runtime.github.stub(routes) result = reviews.get_pull_request_reviews_result(runtime, 42) @@ -80,7 +80,7 @@ def test_pull_request_read_result_reports_not_found(monkeypatch): "pulls/42", result=github_result(404, {"message": "missing"}), ) - runtime.stub_github(routes) + runtime.github.stub(routes) result = reviews._pull_request_read_result(runtime, 42) @@ -95,7 +95,7 @@ def test_pull_request_read_result_reports_invalid_payload(monkeypatch): status_code=200, payload=["not", "a", "dict"], ) - runtime.stub_github(routes) + runtime.github.stub(routes) result = reviews._pull_request_read_result(runtime, 42) diff --git a/tests/unit/reviewer_bot/test_reviews_live_fetch.py b/tests/unit/reviewer_bot/test_reviews_live_fetch.py index 9e54ef7c6..3aea57252 100644 --- a/tests/unit/reviewer_bot/test_reviews_live_fetch.py +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -24,7 +24,7 @@ def _runtime(monkeypatch, routes=None): runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) runtime.get_user_permission_status = lambda username, required_permission="push": "granted" if routes is not None: - runtime.stub_github(routes) + runtime.github.stub(routes) return runtime diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 5e2edfc52..52008826c 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -62,7 +62,7 @@ def test_sweeper_creates_keyed_deferred_gaps_for_visible_comments_reviews_and_di ) .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [ pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="APPROVED"), pull_request_review_event(303, submitted_at="2026-03-25T09:00:00Z", updated_at="2026-03-25T12:00:00Z", state="DISMISSED"), @@ -107,7 +107,7 @@ def test_sweeper_creates_keyed_deferred_gap_for_visible_review_comments(monkeypa payload={"workflow_runs": []}, ) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [] assert sweeper.sweep_deferred_gaps(runtime, state) is True @@ -132,7 +132,7 @@ def test_sweeper_skips_dismissed_reviews_already_reconciled_by_source_event_key( .add_request("GET", "issues/42/comments?per_page=100&page=2", status_code=200, payload=[]) .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(303, submitted_at="2026-03-17T09:00:00Z", updated_at="2026-03-17T12:00:00Z", state="DISMISSED")] assert sweeper.sweep_deferred_gaps(runtime, state) is False @@ -166,7 +166,7 @@ def test_sweeper_skips_events_already_reconciled_by_source_event_key(monkeypatch ) .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-17T11:00:00Z", state="APPROVED")] assert sweeper.sweep_deferred_gaps(runtime, state) is False @@ -189,7 +189,7 @@ def test_discover_visible_comment_events_skips_github_actions_and_bot_comments(m ], ) routes.add_request("GET", "issues/42/comments?per_page=100&page=2", status_code=200, payload=[]) - runtime.stub_github(routes) + runtime.github.stub(routes) discovered, complete = sweeper._discover_visible_comment_events(runtime, 42, review) @@ -222,7 +222,7 @@ def test_sweeper_visible_review_repair_refreshes_current_reviewer_activity_witho .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")] assert sweeper.sweep_deferred_gaps(runtime, state) is True @@ -259,7 +259,7 @@ def test_visible_review_repair_does_not_clear_transition_warning_for_stale_repla .add_request("GET", "pulls/42/reviews?per_page=100&page=1", status_code=200, payload=[]) .add_request("GET", "pulls/42/comments?per_page=100", status_code=200, payload=[]) ) - runtime.stub_github(routes) + runtime.github.stub(routes) runtime.get_pull_request_reviews = lambda issue_number: [pull_request_review_event(202, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1")] assert sweeper.sweep_deferred_gaps(runtime, state) is True From d75c1a351bec51a07a46dadea10544f79170eb4f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:08:50 +0900 Subject: [PATCH 107/209] test(reviewer-bot): add shared typed request builders --- .../test_support_layer_ownership.py | 9 ++ tests/fixtures/commands_harness.py | 27 ++++- tests/fixtures/comment_routing_harness.py | 11 +- tests/fixtures/reviewer_bot_builders.py | 104 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py index b0a6bf6dc..b7b74cdb4 100644 --- a/tests/contract/reviewer_bot/test_support_layer_ownership.py +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -24,6 +24,15 @@ def test_support_layer_has_owned_env_and_recorder_modules(): assert reviewer_bot_recorders is not None +def test_support_layer_has_owned_typed_builder_home(): + builders_text = _read("tests/fixtures/reviewer_bot_builders.py") + + assert "def build_assignment_request(" in builders_text + assert "def build_privileged_command_request(" in builders_text + assert "def build_comment_event_request(" in builders_text + assert "def build_pr_comment_trust_context(" in builders_text + + def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes(): assert reviewer_bot_fakes.RouteGitHubApi is not None assert reviewer_bot_fakes.github_result is not None diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index 8c07412fc..b478d0499 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -11,6 +11,10 @@ from scripts.reviewer_bot_lib.config import AssignmentAttempt from .fake_runtime import FakeReviewerBotRuntime +from .reviewer_bot_builders import ( + build_assignment_request, + build_privileged_command_request, +) from .reviewer_bot_env import set_env_values from .reviewer_bot_recorders import record_comments @@ -116,13 +120,32 @@ def automation_runner(self) -> AutomationRunner: return runner def assignment_request(self, *, issue_number: int): - return event_inputs.build_assignment_request(issue_number=issue_number) + labels = tuple(event_inputs.parse_issue_labels_env()) + return build_assignment_request( + issue_number=issue_number, + issue_author=self.config.get("ISSUE_AUTHOR", ""), + is_pull_request=self.config.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=labels, + repo_owner=self.config.get("REPO_OWNER", ""), + repo_name=self.config.get("REPO_NAME", ""), + ) def privileged_request(self, *, issue_number: int, actor: str = "", command_name: str = ""): - return event_inputs.build_privileged_command_request( + labels = tuple(event_inputs.parse_issue_labels_env()) + return build_privileged_command_request( issue_number=issue_number, actor=actor, command_name=command_name, + is_pull_request=self.config.get("IS_PULL_REQUEST", "false").lower() == "true", + issue_labels=labels, + target_repo_root=self.config.get("REVIEWER_BOT_TARGET_REPO_ROOT", ""), + workflow_run_reconcile_pr_number=( + int(self.config.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "0") or 0) + if self.config.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() + else None + ), + workflow_run_reconcile_head_sha=self.config.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", ""), + workflow_run_head_sha=self.config.get("WORKFLOW_RUN_HEAD_SHA", ""), ) def handle_assign(self, state: dict, issue_number: int, username: str): diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 9b57ebc2e..704926d78 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -4,6 +4,10 @@ from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext from .fake_runtime import FakeReviewerBotRuntime +from .reviewer_bot_builders import ( + build_comment_event_request, + build_pr_comment_trust_context, +) from .reviewer_bot_env import set_env_values from .reviewer_bot_fakes import RouteGitHubApi from .reviewer_bot_recorders import record_comment_side_effects @@ -52,7 +56,7 @@ def request( comment_source_event_key: str = "", comment_user_type: str = "User", ) -> CommentEventRequest: - return CommentEventRequest( + return build_comment_event_request( issue_number=issue_number, is_pull_request=is_pull_request, issue_state=issue_state, @@ -63,6 +67,9 @@ def request( comment_created_at=comment_created_at, comment_source_event_key=comment_source_event_key, comment_user_type=comment_user_type, + comment_sender_type="", + comment_installation_id="", + comment_performed_via_github_app=False, ) def trust_context( @@ -75,7 +82,7 @@ def trust_context( github_run_id: int = 0, github_run_attempt: int = 0, ) -> PrCommentTrustContext: - return PrCommentTrustContext( + return build_pr_comment_trust_context( github_repository=github_repository, comment_author_association=comment_author_association, current_workflow_file=current_workflow_file, diff --git a/tests/fixtures/reviewer_bot_builders.py b/tests/fixtures/reviewer_bot_builders.py index aa8262e84..d0765b10a 100644 --- a/tests/fixtures/reviewer_bot_builders.py +++ b/tests/fixtures/reviewer_bot_builders.py @@ -1,4 +1,108 @@ from scripts.reviewer_bot_lib import review_state +from scripts.reviewer_bot_lib.context import ( + AssignmentRequest, + CommentEventRequest, + PrCommentTrustContext, + PrivilegedCommandRequest, +) + + +def build_assignment_request( + *, + issue_number: int, + issue_author: str = "", + is_pull_request: bool = False, + issue_labels: tuple[str, ...] = (), + repo_owner: str = "", + repo_name: str = "", +) -> AssignmentRequest: + return AssignmentRequest( + issue_number=issue_number, + issue_author=issue_author, + is_pull_request=is_pull_request, + issue_labels=issue_labels, + repo_owner=repo_owner, + repo_name=repo_name, + ) + + +def build_privileged_command_request( + *, + issue_number: int, + actor: str = "", + command_name: str = "", + is_pull_request: bool = False, + issue_labels: tuple[str, ...] = (), + target_repo_root: str = "", + workflow_run_reconcile_pr_number: int | None = None, + workflow_run_reconcile_head_sha: str = "", + workflow_run_head_sha: str = "", +) -> PrivilegedCommandRequest: + return PrivilegedCommandRequest( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=is_pull_request, + issue_labels=issue_labels, + target_repo_root=target_repo_root, + workflow_run_reconcile_pr_number=workflow_run_reconcile_pr_number, + workflow_run_reconcile_head_sha=workflow_run_reconcile_head_sha, + workflow_run_head_sha=workflow_run_head_sha, + ) + + +def build_comment_event_request( + *, + issue_number: int, + is_pull_request: bool, + issue_state: str = "", + issue_author: str = "", + comment_id: int = 0, + comment_author: str = "", + comment_author_id: int = 0, + comment_body: str = "", + comment_created_at: str = "", + comment_source_event_key: str = "", + comment_user_type: str = "", + comment_sender_type: str = "", + comment_installation_id: str = "", + comment_performed_via_github_app: bool = False, +) -> CommentEventRequest: + return CommentEventRequest( + issue_number=issue_number, + is_pull_request=is_pull_request, + issue_state=issue_state, + issue_author=issue_author, + comment_id=comment_id, + comment_author=comment_author, + comment_author_id=comment_author_id, + comment_body=comment_body, + comment_created_at=comment_created_at, + comment_source_event_key=comment_source_event_key, + comment_user_type=comment_user_type, + comment_sender_type=comment_sender_type, + comment_installation_id=comment_installation_id, + comment_performed_via_github_app=comment_performed_via_github_app, + ) + + +def build_pr_comment_trust_context( + *, + github_repository: str = "", + comment_author_association: str = "", + current_workflow_file: str = "", + github_ref: str = "", + github_run_id: int = 0, + github_run_attempt: int = 0, +) -> PrCommentTrustContext: + return PrCommentTrustContext( + github_repository=github_repository, + comment_author_association=comment_author_association, + current_workflow_file=current_workflow_file, + github_ref=github_ref, + github_run_id=github_run_id, + github_run_attempt=github_run_attempt, + ) def make_tracked_review_state( From aed6b7cf296c21546d783e3c0fa63c7d108967a4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:18:22 +0900 Subject: [PATCH 108/209] refactor(reviewer-bot): make command tests typed-first --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 28 ++-- .../reviewer_bot_lib/comment_application.py | 63 +++++++- scripts/reviewer_bot_lib/context.py | 13 +- scripts/reviewer_bot_lib/runtime.py | 26 ++-- tests/fixtures/commands_harness.py | 137 +++++++++++++++--- tests/fixtures/fake_runtime.py | 28 ++-- .../test_accept_no_fls_changes.py | 33 +++-- tests/unit/reviewer_bot/test_commands.py | 37 +++-- 8 files changed, 263 insertions(+), 102 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index c6c4410b4..dd62b4344 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -100,34 +100,34 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> handle_transition_notice=lambda current_state, issue_number, reviewer: lifecycle.handle_transition_notice( runtime, current_state, issue_number, reviewer ), - handle_pass_command=lambda current_state, issue_number, comment_author, reason: commands.handle_pass_command( - runtime, current_state, issue_number, comment_author, reason + handle_pass_command=lambda current_state, issue_number, comment_author, reason, request=None: commands.handle_pass_command( + runtime, current_state, issue_number, comment_author, reason, request=request ), - handle_pass_until_command=lambda current_state, issue_number, comment_author, return_date, reason: commands.handle_pass_until_command( - runtime, current_state, issue_number, comment_author, return_date, reason + handle_pass_until_command=lambda current_state, issue_number, comment_author, return_date, reason, request=None: commands.handle_pass_until_command( + runtime, current_state, issue_number, comment_author, return_date, reason, request=request ), - handle_label_command=lambda current_state, issue_number, label_string: commands.handle_label_command( - runtime, current_state, issue_number, label_string + handle_label_command=lambda current_state, issue_number, label_string, request=None: commands.handle_label_command( + runtime, current_state, issue_number, label_string, request=request ), handle_sync_members_command=lambda current_state: commands.handle_sync_members_command( runtime, current_state ), handle_queue_command=lambda current_state: commands.handle_queue_command(runtime, current_state), handle_commands_command=lambda: commands.handle_commands_command(runtime), - handle_claim_command=lambda current_state, issue_number, comment_author: commands.handle_claim_command( - runtime, current_state, issue_number, comment_author + handle_claim_command=lambda current_state, issue_number, comment_author, request=None: commands.handle_claim_command( + runtime, current_state, issue_number, comment_author, request=request ), - handle_release_command=lambda current_state, issue_number, comment_author, args=None: commands.handle_release_command( - runtime, current_state, issue_number, comment_author, args + handle_release_command=lambda current_state, issue_number, comment_author, args=None, request=None: commands.handle_release_command( + runtime, current_state, issue_number, comment_author, args, request=request ), handle_rectify_command=lambda current_state, issue_number, comment_author: reconcile.handle_rectify_command( runtime, current_state, issue_number, comment_author ), - handle_assign_command=lambda current_state, issue_number, username: commands.handle_assign_command( - runtime, current_state, issue_number, username + handle_assign_command=lambda current_state, issue_number, username, request=None: commands.handle_assign_command( + runtime, current_state, issue_number, username, request=request ), - handle_assign_from_queue_command=lambda current_state, issue_number: commands.handle_assign_from_queue_command( - runtime, current_state, issue_number + handle_assign_from_queue_command=lambda current_state, issue_number, request=None: commands.handle_assign_from_queue_command( + runtime, current_state, issue_number, request=request ), handle_accept_no_fls_changes_command=lambda issue_number, comment_author: automation.handle_accept_no_fls_changes_command( runtime, issue_number, comment_author diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py index 11a36566d..896454888 100644 --- a/scripts/reviewer_bot_lib/comment_application.py +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -5,7 +5,7 @@ import hashlib from datetime import datetime, timezone -from .context import CommentEventRequest +from .context import AssignmentRequest, CommentEventRequest from .review_state import ( accept_channel_event, ensure_review_entry, @@ -85,6 +85,14 @@ def store_pending_privileged_command(review_data: dict, issue_number: int, sourc return True +def build_assignment_request_from_comment(request: CommentEventRequest) -> AssignmentRequest: + return AssignmentRequest( + issue_number=request.issue_number, + issue_author=request.issue_author, + is_pull_request=request.is_pull_request, + ) + + def validate_accept_no_fls_changes_handoff( bot, request: CommentEventRequest, @@ -153,17 +161,36 @@ def apply_comment_command( response = "" success = False state_changed = False + assignment_request = build_assignment_request_from_comment(request) if command == "pass": - response, success = bot.handle_pass_command(state, issue_number, comment_author, " ".join(args) if args else None) + response, success = bot.handle_pass_command( + state, + issue_number, + comment_author, + " ".join(args) if args else None, + request=assignment_request, + ) state_changed = success elif command == "away": if args: - response, success = bot.handle_pass_until_command(state, issue_number, comment_author, args[0], " ".join(args[1:]) if len(args) > 1 else None) + response, success = bot.handle_pass_until_command( + state, + issue_number, + comment_author, + args[0], + " ".join(args[1:]) if len(args) > 1 else None, + request=assignment_request, + ) state_changed = success else: response = f"❌ Missing date. Usage: `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]`" elif command == "label": - response, success, state_changed = bot.handle_label_command(state, issue_number, " ".join(args)) + response, success, state_changed = bot.handle_label_command( + state, + issue_number, + " ".join(args), + request=assignment_request, + ) elif command == "sync-members": response, success = bot.handle_sync_members_command(state) state_changed = success @@ -172,18 +199,38 @@ def apply_comment_command( elif command == "commands": response, success = bot.handle_commands_command() elif command == "claim": - response, success = bot.handle_claim_command(state, issue_number, comment_author) + response, success = bot.handle_claim_command( + state, + issue_number, + comment_author, + request=assignment_request, + ) state_changed = success elif command == "release": - response, success = bot.handle_release_command(state, issue_number, comment_author, list(args)) + response, success = bot.handle_release_command( + state, + issue_number, + comment_author, + list(args), + request=assignment_request, + ) state_changed = success elif command == "rectify": response, success, state_changed = bot.handle_rectify_command(state, issue_number, comment_author) elif command == "r?-user": - response, success = bot.handle_assign_command(state, issue_number, args[0] if args else "") + response, success = bot.handle_assign_command( + state, + issue_number, + args[0] if args else "", + request=assignment_request, + ) state_changed = success elif command == "assign-from-queue": - response, success = bot.handle_assign_from_queue_command(state, issue_number) + response, success = bot.handle_assign_from_queue_command( + state, + issue_number, + request=assignment_request, + ) state_changed = success elif command == "r?": response = f"❌ Missing target. Usage:\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Assign next reviewer from queue" diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 350809ec2..f575fe2ff 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -319,7 +319,7 @@ def compute_reviewer_response_state( reviews: list[dict] | None = None, ) -> dict[str, object]: ... def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: ... - def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): ... + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request: AssignmentRequest | None = None): ... def handle_pass_until_command( self, state: dict, @@ -327,15 +327,16 @@ def handle_pass_until_command( comment_author: str, return_date: str, reason: str | None, + request: AssignmentRequest | None = None, ): ... - def handle_label_command(self, state: dict, issue_number: int, label_string: str): ... + def handle_label_command(self, state: dict, issue_number: int, label_string: str, request: AssignmentRequest | None = None): ... def handle_sync_members_command(self, state: dict): ... def handle_queue_command(self, state: dict): ... def handle_commands_command(self): ... - def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): ... - def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): ... + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request: AssignmentRequest | None = None): ... + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request: AssignmentRequest | None = None): ... def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): ... - def handle_assign_command(self, state: dict, issue_number: int, username: str): ... - def handle_assign_from_queue_command(self, state: dict, issue_number: int): ... + def handle_assign_command(self, state: dict, issue_number: int, username: str, request: AssignmentRequest | None = None): ... + def handle_assign_from_queue_command(self, state: dict, issue_number: int, request: AssignmentRequest | None = None): ... def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): ... def get_commands_help(self) -> str: ... diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index d3fea6eaa..feff7f7b8 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -256,8 +256,8 @@ def maybe_record_head_observation_repair(self, issue_number: int, review_data: d def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: return self.adapters.handle_transition_notice(state, issue_number, reviewer) - def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): - return self.adapters.handle_pass_command(state, issue_number, comment_author, reason) + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request=None): + return self.adapters.handle_pass_command(state, issue_number, comment_author, reason, request=request) def handle_pass_until_command( self, @@ -266,6 +266,7 @@ def handle_pass_until_command( comment_author: str, return_date: str, reason: str | None, + request=None, ): return self.adapters.handle_pass_until_command( state, @@ -273,10 +274,11 @@ def handle_pass_until_command( comment_author, return_date, reason, + request=request, ) - def handle_label_command(self, state: dict, issue_number: int, label_string: str): - return self.adapters.handle_label_command(state, issue_number, label_string) + def handle_label_command(self, state: dict, issue_number: int, label_string: str, request=None): + return self.adapters.handle_label_command(state, issue_number, label_string, request=request) def handle_sync_members_command(self, state: dict): return self.adapters.handle_sync_members_command(state) @@ -287,20 +289,20 @@ def handle_queue_command(self, state: dict): def handle_commands_command(self): return self.adapters.handle_commands_command() - def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): - return self.adapters.handle_claim_command(state, issue_number, comment_author) + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request=None): + return self.adapters.handle_claim_command(state, issue_number, comment_author, request=request) - def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): - return self.adapters.handle_release_command(state, issue_number, comment_author, args) + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request=None): + return self.adapters.handle_release_command(state, issue_number, comment_author, args, request=request) def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): return self.adapters.handle_rectify_command(state, issue_number, comment_author) - def handle_assign_command(self, state: dict, issue_number: int, username: str): - return self.adapters.handle_assign_command(state, issue_number, username) + def handle_assign_command(self, state: dict, issue_number: int, username: str, request=None): + return self.adapters.handle_assign_command(state, issue_number, username, request=request) - def handle_assign_from_queue_command(self, state: dict, issue_number: int): - return self.adapters.handle_assign_from_queue_command(state, issue_number) + def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): + return self.adapters.handle_assign_from_queue_command(state, issue_number, request=request) def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): return self.adapters.handle_accept_no_fls_changes_command(issue_number, comment_author) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index b478d0499..921a88722 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -9,10 +9,13 @@ from scripts.reviewer_bot_lib import maintenance as maintenance_module from scripts.reviewer_bot_lib import reconcile as reconcile_module from scripts.reviewer_bot_lib.config import AssignmentAttempt +from scripts.reviewer_bot_lib.context import CommentEventRequest, PrCommentTrustContext from .fake_runtime import FakeReviewerBotRuntime from .reviewer_bot_builders import ( build_assignment_request, + build_comment_event_request, + build_pr_comment_trust_context, build_privileged_command_request, ) from .reviewer_bot_env import set_env_values @@ -42,7 +45,7 @@ def __init__(self, monkeypatch): self.runtime = FakeReviewerBotRuntime(monkeypatch) self.config = self.runtime.config - def set_comment_command( + def wrapper_set_comment_command( self, *, issue_number: int, @@ -56,7 +59,7 @@ def set_comment_command( ref: str = "", comment_id: int = 100, created_at: str = "2026-03-17T10:00:00Z", - ) -> None: + ) -> None: values = { "ISSUE_NUMBER": issue_number, "IS_PULL_REQUEST": str(is_pull_request).lower(), @@ -77,10 +80,10 @@ def set_comment_command( values["GITHUB_REF"] = ref set_env_values(self.config, **values) - def set_assignment_context(self, *, issue_author: str, is_pull_request: bool) -> None: + def wrapper_set_assignment_context(self, *, issue_author: str, is_pull_request: bool) -> None: set_env_values(self.config, ISSUE_AUTHOR=issue_author, IS_PULL_REQUEST=str(is_pull_request).lower()) - def set_privileged_context( + def wrapper_set_privileged_context( self, *, labels: list[str], @@ -93,9 +96,14 @@ def set_privileged_context( if target_repo_root is not None: self.config.set("REVIEWER_BOT_TARGET_REPO_ROOT", target_repo_root) - def set_manual_dispatch(self, *, source_event_key: str) -> None: + def wrapper_set_manual_dispatch(self, *, source_event_key: str) -> None: set_env_values(self.config, MANUAL_ACTION="execute-pending-privileged-command", PRIVILEGED_SOURCE_EVENT_KEY=source_event_key) + set_comment_command = wrapper_set_comment_command + set_assignment_context = wrapper_set_assignment_context + set_privileged_context = wrapper_set_privileged_context + set_manual_dispatch = wrapper_set_manual_dispatch + def capture_posted_comments(self): return record_comments(self.runtime) @@ -130,6 +138,25 @@ def assignment_request(self, *, issue_number: int): repo_name=self.config.get("REPO_NAME", ""), ) + def typed_assignment_request( + self, + *, + issue_number: int, + issue_author: str = "", + is_pull_request: bool = False, + issue_labels: tuple[str, ...] = (), + repo_owner: str = "", + repo_name: str = "", + ): + return build_assignment_request( + issue_number=issue_number, + issue_author=issue_author, + is_pull_request=is_pull_request, + issue_labels=issue_labels, + repo_owner=repo_owner, + repo_name=repo_name, + ) + def privileged_request(self, *, issue_number: int, actor: str = "", command_name: str = ""): labels = tuple(event_inputs.parse_issue_labels_env()) return build_privileged_command_request( @@ -148,35 +175,101 @@ def privileged_request(self, *, issue_number: int, actor: str = "", command_name workflow_run_head_sha=self.config.get("WORKFLOW_RUN_HEAD_SHA", ""), ) - def handle_assign(self, state: dict, issue_number: int, username: str): + def typed_privileged_request( + self, + *, + issue_number: int, + actor: str = "", + command_name: str = "", + is_pull_request: bool = False, + issue_labels: tuple[str, ...] = (), + target_repo_root: str = "", + workflow_run_reconcile_pr_number: int | None = None, + workflow_run_reconcile_head_sha: str = "", + workflow_run_head_sha: str = "", + ): + return build_privileged_command_request( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=is_pull_request, + issue_labels=issue_labels, + target_repo_root=target_repo_root, + workflow_run_reconcile_pr_number=workflow_run_reconcile_pr_number, + workflow_run_reconcile_head_sha=workflow_run_reconcile_head_sha, + workflow_run_head_sha=workflow_run_head_sha, + ) + + def typed_comment_request( + self, + *, + issue_number: int, + actor: str, + body: str, + issue_author: str, + is_pull_request: bool = False, + comment_id: int = 100, + created_at: str = "2026-03-17T10:00:00Z", + ) -> CommentEventRequest: + return build_comment_event_request( + issue_number=issue_number, + is_pull_request=is_pull_request, + issue_author=issue_author, + comment_id=comment_id, + comment_author=actor, + comment_body=body, + comment_created_at=created_at, + comment_user_type="User", + ) + + def typed_trust_context( + self, + *, + author_association: str = "", + workflow_file: str = "", + repository: str = "", + ref: str = "", + run_id: int = 0, + run_attempt: int = 0, + ) -> PrCommentTrustContext: + return build_pr_comment_trust_context( + github_repository=repository, + comment_author_association=author_association, + current_workflow_file=workflow_file, + github_ref=ref, + github_run_id=run_id, + github_run_attempt=run_attempt, + ) + + def handle_assign(self, state: dict, issue_number: int, username: str, *, request=None): return commands_module.handle_assign_command( self.runtime, state, issue_number, username, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) - def handle_claim(self, state: dict, issue_number: int, comment_author: str): + def handle_claim(self, state: dict, issue_number: int, comment_author: str, *, request=None): return commands_module.handle_claim_command( self.runtime, state, issue_number, comment_author, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) - def handle_pass(self, state: dict, issue_number: int, comment_author: str, reason: str | None): + def handle_pass(self, state: dict, issue_number: int, comment_author: str, reason: str | None, *, request=None): return commands_module.handle_pass_command( self.runtime, state, issue_number, comment_author, reason, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) - def handle_pass_until(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None): + def handle_pass_until(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None, *, request=None): return commands_module.handle_pass_until_command( self.runtime, state, @@ -184,25 +277,25 @@ def handle_pass_until(self, state: dict, issue_number: int, comment_author: str, comment_author, return_date, reason, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) - def handle_release(self, state: dict, issue_number: int, comment_author: str, args=None): + def handle_release(self, state: dict, issue_number: int, comment_author: str, args=None, *, request=None): return commands_module.handle_release_command( self.runtime, state, issue_number, comment_author, args, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) - def handle_assign_from_queue(self, state: dict, issue_number: int): + def handle_assign_from_queue(self, state: dict, issue_number: int, *, request=None): return commands_module.handle_assign_from_queue_command( self.runtime, state, issue_number, - request=self.assignment_request(issue_number=issue_number), + request=request or self.assignment_request(issue_number=issue_number), ) def handle_rectify(self, state: dict, issue_number: int, comment_author: str): @@ -213,20 +306,20 @@ def handle_rectify(self, state: dict, issue_number: int, comment_author: str): comment_author, ) - def handle_accept_no_fls_changes(self, issue_number: int, comment_author: str): + def handle_accept_no_fls_changes(self, issue_number: int, comment_author: str, *, request=None): return automation_module.handle_accept_no_fls_changes_command( self.runtime, issue_number, comment_author, - request=self.privileged_request(issue_number=issue_number, actor=comment_author, command_name="accept-no-fls-changes"), + request=request or self.privileged_request(issue_number=issue_number, actor=comment_author, command_name="accept-no-fls-changes"), ) - def handle_comment_event(self, state: dict): + def handle_comment_event(self, state: dict, *, request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None): return comment_routing_module.handle_comment_event( self.runtime, state, - event_inputs.build_comment_event_request(), - event_inputs.build_pr_comment_trust_context(), + request or event_inputs.build_comment_event_request(), + trust_context or event_inputs.build_pr_comment_trust_context(), ) def handle_manual_dispatch(self, state: dict): diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 5c867a953..2ced85fd0 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -431,14 +431,14 @@ def github_graphql(self, query: str, variables=None, *, token=None): def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: return lifecycle_module.handle_transition_notice(self, state, issue_number, reviewer) - def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None): - return commands_module.handle_pass_command(self, state, issue_number, comment_author, reason) + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request=None): + return commands_module.handle_pass_command(self, state, issue_number, comment_author, reason, request=request) - def handle_pass_until_command(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None): - return commands_module.handle_pass_until_command(self, state, issue_number, comment_author, return_date, reason) + def handle_pass_until_command(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None, request=None): + return commands_module.handle_pass_until_command(self, state, issue_number, comment_author, return_date, reason, request=request) - def handle_label_command(self, state: dict, issue_number: int, label_string: str): - return commands_module.handle_label_command(self, state, issue_number, label_string) + def handle_label_command(self, state: dict, issue_number: int, label_string: str, request=None): + return commands_module.handle_label_command(self, state, issue_number, label_string, request=request) def handle_sync_members_command(self, state: dict): return commands_module.handle_sync_members_command(self, state) @@ -449,17 +449,17 @@ def handle_queue_command(self, state: dict): def handle_commands_command(self): return commands_module.handle_commands_command(self) - def handle_claim_command(self, state: dict, issue_number: int, comment_author: str): - return commands_module.handle_claim_command(self, state, issue_number, comment_author) + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request=None): + return commands_module.handle_claim_command(self, state, issue_number, comment_author, request=request) - def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None): - return commands_module.handle_release_command(self, state, issue_number, comment_author, args) + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request=None): + return commands_module.handle_release_command(self, state, issue_number, comment_author, args, request=request) - def handle_assign_command(self, state: dict, issue_number: int, username: str): - return commands_module.handle_assign_command(self, state, issue_number, username) + def handle_assign_command(self, state: dict, issue_number: int, username: str, request=None): + return commands_module.handle_assign_command(self, state, issue_number, username, request=request) - def handle_assign_from_queue_command(self, state: dict, issue_number: int): - return commands_module.handle_assign_from_queue_command(self, state, issue_number) + def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): + return commands_module.handle_assign_from_queue_command(self, state, issue_number, request=request) def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author) diff --git a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py index 986fef4e8..44df84cef 100644 --- a/tests/integration/reviewer_bot/test_accept_no_fls_changes.py +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -30,10 +30,13 @@ def test_list_changed_files_reports_tracked_changes_only(monkeypatch, tmp_path): def test_accept_no_fls_changes_honors_explicit_target_repo_root(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) - harness.set_privileged_context( - labels=[FLS_AUDIT_LABEL], + request = harness.typed_privileged_request( + issue_number=42, + actor="alice", + command_name="accept-no-fls-changes", is_pull_request=False, - target_repo_root=tmp_path, + issue_labels=(FLS_AUDIT_LABEL,), + target_repo_root=str(tmp_path), ) harness.stub_permission("granted") observed = {"cwd": None} @@ -44,17 +47,20 @@ def fake_list_changed_files(repo_root): harness.runtime.list_changed_files = fake_list_changed_files - message, success = harness.handle_accept_no_fls_changes(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice", request=request) assert (message, success) == ("❌ Working tree is not clean; refusing to update spec.lock.", False) assert observed["cwd"] == tmp_path def test_accept_no_fls_changes_uses_locked_nested_uv_commands(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) - harness.set_privileged_context( - labels=[FLS_AUDIT_LABEL], + request = harness.typed_privileged_request( + issue_number=42, + actor="alice", + command_name="accept-no-fls-changes", is_pull_request=False, - target_repo_root=tmp_path, + issue_labels=(FLS_AUDIT_LABEL,), + target_repo_root=str(tmp_path), ) harness.stub_permission("granted") list_calls = {"count": 0} @@ -70,7 +76,7 @@ def fake_list_changed_files(repo_root): harness.runtime.list_changed_files = fake_list_changed_files - message, success = harness.handle_accept_no_fls_changes(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice", request=request) assert (message, success) == ("✅ `src/spec.lock` is already up to date; no PR needed.", True) assert list_calls["count"] == 2 @@ -81,10 +87,13 @@ def fake_list_changed_files(repo_root): def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, tmp_path): harness = CommandHarness(monkeypatch) - harness.set_privileged_context( - labels=[FLS_AUDIT_LABEL], + request = harness.typed_privileged_request( + issue_number=42, + actor="alice", + command_name="accept-no-fls-changes", is_pull_request=False, - target_repo_root=tmp_path, + issue_labels=(FLS_AUDIT_LABEL,), + target_repo_root=str(tmp_path), ) harness.stub_permission("granted") harness.runtime.list_changed_files = lambda repo_root: [] @@ -95,7 +104,7 @@ def test_accept_no_fls_changes_surfaces_locked_uv_failure_details(monkeypatch, t stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", ) - message, success = harness.handle_accept_no_fls_changes(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice", request=request) assert success is False assert "Audit command failed." in message diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index 9ffc296ba..db7462365 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -19,7 +19,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - harness.set_comment_command( + request = harness.typed_comment_request( issue_number=42, actor="alice", body="@guidelines-bot /label +sign-off: create pr", @@ -34,7 +34,7 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_stat harness.runtime.add_reaction = lambda *args, **kwargs: True posted = harness.capture_posted_comments() - assert harness.handle_comment_event(state) is True + assert harness.handle_comment_event(state, request=request) is True assert review["review_completion_source"] == "issue_label: sign-off: create pr" assert review["current_cycle_completion"]["completed"] is True assert posted == [(42, "✅ Added label `sign-off: create pr`")] @@ -46,12 +46,14 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - harness.set_comment_command( + request = harness.typed_comment_request( issue_number=42, actor="alice", body="@guidelines-bot /label +sign-off: create pr", issue_author="dana", is_pull_request=True, + ) + trust_context = harness.typed_trust_context( author_association="MEMBER", workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", repository="rustfoundation/safety-critical-rust-coding-guidelines", @@ -60,6 +62,7 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) harness.runtime.github_api = lambda method, endpoint, data=None: { "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, "user": {"login": "dana"}, + "pull_request": {}, } harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] harness.runtime.add_label = lambda issue_number, label: True @@ -69,7 +72,7 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) harness.runtime.add_reaction = lambda *args, **kwargs: True harness.runtime.post_comment = lambda *args, **kwargs: True - assert harness.handle_comment_event(state) is False + assert harness.handle_comment_event(state, request=request, trust_context=trust_context) is False assert review["review_completion_source"] is None @@ -101,13 +104,13 @@ def test_assign_command_posts_pr_guidance_on_success(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + request = harness.typed_assignment_request(issue_number=42, issue_author="PLeVasseur", is_pull_request=True) harness.stub_assignees([]) harness.stub_assignment() posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = harness.handle_assign(state, 42, "@felix91gr") + response, success = harness.handle_assign(state, 42, "@felix91gr", request=request) assert success is True assert response == "✅ @felix91gr has been assigned as reviewer." @@ -118,13 +121,13 @@ def test_claim_command_posts_pr_guidance_on_success(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + request = harness.typed_assignment_request(issue_number=42, issue_author="PLeVasseur", is_pull_request=True) harness.stub_assignees([]) harness.stub_assignment() posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = harness.handle_claim(state, 42, "felix91gr") + response, success = harness.handle_claim(state, 42, "felix91gr", request=request) assert success is True assert response == "✅ @felix91gr has claimed this review." @@ -141,14 +144,14 @@ def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + request = harness.typed_assignment_request(issue_number=42, issue_author="PLeVasseur", is_pull_request=True) harness.stub_assignees(["alice"]) harness.stub_assignment() harness.runtime.unassign_reviewer = lambda issue_number, username: True posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = harness.handle_pass(state, 42, "alice", None) + response, success = harness.handle_pass(state, 42, "alice", None, request=request) assert success is True assert "@felix91gr is now assigned as the reviewer." in response @@ -159,13 +162,13 @@ def test_assign_from_queue_posts_guidance_only_once(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] - harness.set_assignment_context(issue_author="PLeVasseur", is_pull_request=True) + request = harness.typed_assignment_request(issue_number=42, issue_author="PLeVasseur", is_pull_request=True) harness.stub_assignees([]) harness.stub_assignment() posted = [] harness.runtime.post_comment = lambda issue_number, body: posted.append(body) or True - response, success = harness.handle_assign_from_queue(state, 42) + response, success = harness.handle_assign_from_queue(state, 42, request=request) assert success is True assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." @@ -174,10 +177,16 @@ def test_assign_from_queue_posts_guidance_only_once(monkeypatch): def test_handle_accept_no_fls_changes_command_fails_closed_when_permission_unavailable(monkeypatch): harness = CommandHarness(monkeypatch) - harness.set_privileged_context(labels=[FLS_AUDIT_LABEL], is_pull_request=False) + request = harness.typed_privileged_request( + issue_number=42, + actor="alice", + command_name="accept-no-fls-changes", + is_pull_request=False, + issue_labels=(FLS_AUDIT_LABEL,), + ) harness.stub_permission("unavailable") - message, success = harness.handle_accept_no_fls_changes(42, "alice") + message, success = harness.handle_accept_no_fls_changes(42, "alice", request=request) assert success is False assert "Unable to verify triage permissions right now" in message From 0c05a83dfa5df45cd7607b47b2c98e6b0c6ce4c8 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:23:01 +0900 Subject: [PATCH 109/209] refactor(reviewer-bot): make replay tests typed-first --- tests/fixtures/comment_routing_harness.py | 17 +++++++++++------ tests/fixtures/reconcile_harness.py | 8 +++++--- .../test_comment_routing_integration.py | 4 ++-- tests/unit/reviewer_bot/test_comment_routing.py | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 704926d78..0fc8b0c44 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -20,17 +20,20 @@ def __init__(self, monkeypatch): self.runtime = FakeReviewerBotRuntime(monkeypatch, github=self.github) self.config = self.runtime.config - def wrapper_request(self, *, issue_number: int | None = None): + def env_build_request(self, *, issue_number: int | None = None): return event_inputs.build_comment_event_request(issue_number=issue_number) - def wrapper_trust_context(self): + def env_build_trust_context(self): return event_inputs.build_pr_comment_trust_context() + wrapper_request = env_build_request + wrapper_trust_context = env_build_trust_context + def handle_comment_event(self, state: dict, request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None): return comment_routing.handle_comment_event( self.runtime, state, - request or self.wrapper_request(), + request or self.env_build_request(), trust_context, ) @@ -38,8 +41,8 @@ def build_observer_payload(self, issue_number: int, request: CommentEventRequest return comment_routing.build_pr_comment_observer_payload( self.runtime, issue_number, - request or self.wrapper_request(issue_number=issue_number), - trust_context or self.wrapper_trust_context(), + request or self.env_build_request(issue_number=issue_number), + trust_context or self.env_build_trust_context(), ) def request( @@ -112,7 +115,7 @@ def add_pull_request_metadata( def capture_comment_side_effects(self): return record_comment_side_effects(self.runtime) - def apply_wrapper_inputs( + def wrapper_apply_inputs( self, *, issue_number: int, @@ -149,3 +152,5 @@ def apply_wrapper_inputs( if github_ref: values["GITHUB_REF"] = github_ref set_env_values(self.config, **values) + + apply_wrapper_inputs = wrapper_apply_inputs diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index f1ae9ad77..af7db2406 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -125,7 +125,7 @@ def __post_init__(self) -> None: self.runtime = FakeReviewerBotRuntime(self.monkeypatch, github=self.github) self.config = self.runtime.config self.runtime.stub_deferred_payload(self.payload) - self.set_trigger_from_payload(self.payload) + self.wrapper_set_trigger_from_payload(self.payload) def handle_workflow_run_event(self, state: dict) -> bool: return reconcile.handle_workflow_run_event(self.runtime, state) @@ -133,10 +133,10 @@ def handle_workflow_run_event(self, state: dict) -> bool: def set_payload(self, payload: dict) -> dict: self.payload = payload self.runtime.stub_deferred_payload(payload) - self.set_trigger_from_payload(payload) + self.wrapper_set_trigger_from_payload(payload) return payload - def set_trigger_from_payload(self, payload: dict, *, conclusion: str = "success") -> None: + def wrapper_set_trigger_from_payload(self, payload: dict, *, conclusion: str = "success") -> None: set_env_values( self.config, WORKFLOW_RUN_TRIGGERING_NAME=payload["source_workflow_name"], @@ -145,6 +145,8 @@ def set_trigger_from_payload(self, payload: dict, *, conclusion: str = "success" WORKFLOW_RUN_TRIGGERING_CONCLUSION=conclusion, ) + set_trigger_from_payload = wrapper_set_trigger_from_payload + def add_pull_request( self, *, diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index bae597700..c31d4f76e 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -111,7 +111,7 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None review["current_reviewer"] = "alice" - harness.apply_wrapper_inputs( + request = harness.request( issue_number=42, is_pull_request=False, issue_state="open", @@ -120,7 +120,7 @@ def test_open_non_pr_plain_text_comment_still_updates_freshness(monkeypatch): comment_body="reviewer-bot validation: contributor plain text comment", ) - assert harness.handle_comment_event(state) is True + assert harness.handle_comment_event(state, request=request) is True accepted = state["active_reviews"]["42"]["contributor_comment"]["accepted"] assert accepted["semantic_key"] == "issue_comment:100" diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index e280bf21c..7a722c1ad 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -137,7 +137,7 @@ def test_route_issue_comment_trust_fails_closed_for_ambiguous_same_repo(monkeypa def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monkeypatch): harness = CommentRoutingHarness(monkeypatch) - harness.apply_wrapper_inputs( + harness.wrapper_apply_inputs( issue_number=42, is_pull_request=True, issue_author="dana", From 79310f766b02c913028386d363b010e7d14d94a5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:30:47 +0900 Subject: [PATCH 110/209] refactor(reviewer-bot): narrow mutable review-state ownership --- scripts/reviewer_bot_lib/commands.py | 9 ++-- scripts/reviewer_bot_lib/reconcile.py | 12 +++-- scripts/reviewer_bot_lib/reviews.py | 49 ++++++++++--------- .../test_review_state_contract.py | 14 +++++- 4 files changed, 50 insertions(+), 34 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index c87463f84..897a910c9 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -3,6 +3,7 @@ import re from datetime import datetime, timezone +from . import review_state from .automation import bot_parse_issue_labels from .automation import ( find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status, @@ -107,7 +108,7 @@ def _apply_assignment_side_effects( ) -> tuple[AssignmentAttempt, str | None]: issue_number = request.issue_number assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) - bot.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + review_state.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) bot.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: @@ -142,7 +143,7 @@ def handle_pass_command( request: AssignmentRequest | None = None, ) -> tuple[str, bool]: assignment_request = request or build_assignment_request(issue_number=issue_number) - issue_data = bot.ensure_review_entry(state, issue_number, create=True) + issue_data = review_state.ensure_review_entry(state, issue_number, create=True) if issue_data is None: return "❌ Unable to load review state.", False passed_reviewer = issue_data.get("current_reviewer") @@ -295,9 +296,9 @@ def handle_label_command( elif bot.add_label(issue_number, label): results.append(f"✅ Added label `{label}`") if label == "sign-off: create pr" and not assignment_request.is_pull_request: - review_data = bot.ensure_review_entry(state, issue_number) + review_data = review_state.ensure_review_entry(state, issue_number) reviewer = review_data.get("current_reviewer") if review_data else None - completion_changed = bot.mark_review_complete( + completion_changed = review_state.mark_review_complete( state, issue_number, reviewer, "issue_label: sign-off: create pr" ) state_changed = completion_changed or state_changed diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 393d3c788..e563d630a 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -12,7 +12,11 @@ ) from .comment_routing import classify_comment_payload, classify_issue_comment_actor from .context import CommentEventRequest -from .review_state import accept_channel_event, record_reviewer_activity +from .review_state import ( + accept_channel_event, + ensure_review_entry, + record_reviewer_activity, +) from .reviews import ( find_triage_approval_after, rebuild_pr_approval_state_result, @@ -298,7 +302,7 @@ def reconcile_active_review_entry( require_pull_request_context: bool = True, completion_source: str = "rectify:reconcile-pr-review", ) -> tuple[str, bool, bool]: - review_data = bot.ensure_review_entry(state, issue_number) + review_data = ensure_review_entry(state, issue_number) if review_data is None: return f"ℹ️ No active review entry exists for #{issue_number}; nothing to rectify.", True, False assigned_reviewer = review_data.get("current_reviewer") @@ -343,7 +347,7 @@ def reconcile_active_review_entry( def handle_rectify_command(bot, state: dict, issue_number: int, comment_author: str) -> tuple[str, bool, bool]: - review_data = bot.ensure_review_entry(state, issue_number) + review_data = ensure_review_entry(state, issue_number) current_reviewer = review_data.get("current_reviewer") if review_data else None is_current_reviewer = ( @@ -875,7 +879,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: if pr_number <= 0: raise RuntimeError("Deferred context is missing a valid PR number") bot.collect_touched_item(pr_number) - review_data = bot.ensure_review_entry(state, pr_number, create=True) + review_data = ensure_review_entry(state, pr_number, create=True) if review_data is None: raise RuntimeError(f"No review entry available for PR #{pr_number}") event_name = parsed_payload.identity.source_event_name diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 76fb251a3..7a8e9e568 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -6,6 +6,7 @@ from typing import Any, Iterable from urllib.parse import quote +from . import review_state from .config import ( MANDATORY_TRIAGE_APPROVER_LABEL, MANDATORY_TRIAGE_ESCALATION_TEMPLATE, @@ -154,7 +155,7 @@ def get_latest_valid_current_reviewer_review_for_cycle( current_reviewer = review_data.get("current_reviewer") if not isinstance(current_reviewer, str) or not current_reviewer.strip(): return None - boundary = get_current_cycle_boundary(bot, review_data) + boundary = _get_current_cycle_boundary(bot, review_data) if boundary is None: return None if reviews is None: @@ -196,7 +197,7 @@ def get_valid_current_reviewer_reviews_for_cycle( current_reviewer = review_data.get("current_reviewer") if not isinstance(current_reviewer, str) or not current_reviewer.strip(): return [] - boundary = get_current_cycle_boundary(bot, review_data) + boundary = _get_current_cycle_boundary(bot, review_data) if boundary is None: return [] if reviews is None: @@ -280,7 +281,7 @@ def accept_reviewer_review_from_live_review(review_data: dict, review: dict, *, record = build_reviewer_review_record_from_live_review(review, actor=actor) if record is None: return False - return accept_channel_event( + return _accept_channel_event( review_data, "reviewer_review", semantic_key=record["semantic_key"], @@ -331,7 +332,7 @@ def refresh_reviewer_review_from_live_preferred_review( previous_activity = review_data.get("last_reviewer_activity") previous_warning = review_data.get("transition_warning_sent") previous_notice = review_data.get("transition_notice_sent_at") - record_reviewer_activity(review_data, submitted_at) + _record_reviewer_activity(review_data, submitted_at) activity_changed = ( previous_activity != review_data.get("last_reviewer_activity") or previous_warning != review_data.get("transition_warning_sent") @@ -349,7 +350,7 @@ def compute_pr_approval_state_result( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> dict[str, object]: - boundary = get_current_cycle_boundary(bot, review_data) + boundary = _get_current_cycle_boundary(bot, review_data) if boundary is None: return _projection_failure("pull_request_unavailable") pull_request_result = _pull_request_read_result(bot, issue_number, pull_request) @@ -496,7 +497,7 @@ def _ensure_dict(review_entry: dict, name: str) -> dict: return value -def ensure_review_entry(state: dict, issue_number: int, create: bool = False) -> dict | None: +def _ensure_review_entry(state: dict, issue_number: int, create: bool = False) -> dict | None: issue_key = str(issue_number) if "active_reviews" not in state or not isinstance(state.get("active_reviews"), dict): state["active_reviews"] = {} @@ -583,7 +584,7 @@ def clear_transition_timers(review_data: dict) -> None: review_data["transition_notice_sent_at"] = None -def record_reviewer_activity(review_data: dict, timestamp: str) -> bool: +def _record_reviewer_activity(review_data: dict, timestamp: str) -> bool: current = parse_github_timestamp(review_data.get("last_reviewer_activity")) candidate = parse_github_timestamp(timestamp) if candidate is None: @@ -595,25 +596,25 @@ def record_reviewer_activity(review_data: dict, timestamp: str) -> bool: return False -def record_transition_notice_sent(review_data: dict, timestamp: str) -> None: +def _record_transition_notice_sent(review_data: dict, timestamp: str) -> None: review_data["transition_notice_sent_at"] = timestamp -def set_current_reviewer( +def _set_current_reviewer( state: dict, issue_number: int, reviewer: str, assignment_method: str = "round-robin", ) -> None: now = _now_iso() - review_data = ensure_review_entry(state, issue_number, create=True) + review_data = _ensure_review_entry(state, issue_number, create=True) if review_data is None: return review_data["current_reviewer"] = reviewer review_data["cycle_started_at"] = now review_data["active_cycle_started_at"] = now review_data["assigned_at"] = now - record_reviewer_activity(review_data, now) + _record_reviewer_activity(review_data, now) review_data["assignment_method"] = assignment_method review_data["review_completed_at"] = None review_data["review_completed_by"] = None @@ -652,7 +653,7 @@ def _compare_records(left: dict | None, right: dict | None) -> int: return 0 -def accept_channel_event( +def _accept_channel_event( review_data: dict, channel_name: str, *, @@ -688,26 +689,26 @@ def accept_channel_event( return True -def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: - review_data = ensure_review_entry(state, issue_number) +def _update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: + review_data = _ensure_review_entry(state, issue_number) if review_data is None: return False current_reviewer = review_data.get("current_reviewer") if not isinstance(current_reviewer, str) or current_reviewer.lower() != reviewer.lower(): return False - record_reviewer_activity(review_data, _now_iso()) + _record_reviewer_activity(review_data, _now_iso()) return True -def mark_review_complete(state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: - review_data = ensure_review_entry(state, issue_number, create=True) +def _mark_review_complete(state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: + review_data = _ensure_review_entry(state, issue_number, create=True) if review_data is None: return False now = _now_iso() review_data["review_completed_at"] = now review_data["review_completed_by"] = reviewer or None review_data["review_completion_source"] = source - record_reviewer_activity(review_data, now) + _record_reviewer_activity(review_data, now) review_data["current_cycle_completion"] = { "completed": True, "completed_at": now, @@ -725,7 +726,7 @@ def is_triage_or_higher(bot, username: str) -> bool: def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) -> bool: - review_data = ensure_review_entry(state, issue_number, create=True) + review_data = _ensure_review_entry(state, issue_number, create=True) if review_data is None: return False now = _now_iso() @@ -751,7 +752,7 @@ def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) - def satisfy_mandatory_approver_requirement(bot, state: dict, issue_number: int, approver: str) -> bool: - review_data = ensure_review_entry(state, issue_number, create=True) + review_data = _ensure_review_entry(state, issue_number, create=True) if review_data is None or not review_data.get("mandatory_approver_required"): return False if review_data.get("mandatory_approver_satisfied_at"): @@ -794,7 +795,7 @@ def collapse_latest_reviews_by_login(reviews: list[dict]) -> dict[str, dict]: return {login: item[2] for login, item in latest_by_login.items()} -def get_current_cycle_boundary(bot, review_data: dict) -> datetime | None: +def _get_current_cycle_boundary(bot, review_data: dict) -> datetime | None: for field in ("active_cycle_started_at", "cycle_started_at", "assigned_at"): boundary = bot.parse_iso8601_timestamp(review_data.get(field)) if boundary is not None: @@ -1110,7 +1111,7 @@ def project_status_labels_for_item( if str(issue_snapshot.get("state", "")).lower() == "closed": return set(), {"state": "closed", "reason": None} - review_data = bot.ensure_review_entry(state, issue_number) + review_data = review_state.ensure_review_entry(state, issue_number) if review_data is None: return set(), {"state": "untracked", "reason": "no_review_entry"} current_reviewer = review_data.get("current_reviewer") @@ -1219,7 +1220,7 @@ def list_open_tracked_review_items(state: dict) -> list[int]: 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) + review_data = _ensure_review_entry(state, issue_number) if review_data is None: return False current_reviewer = review_data.get("current_reviewer") @@ -1227,7 +1228,7 @@ def handle_pr_approved_review(bot, state: dict, issue_number: int, review_author author_is_triage = is_triage_or_higher(bot, review_author) state_changed = False if author_is_designated: - if mark_review_complete(state, issue_number, review_author, completion_source): + if _mark_review_complete(state, issue_number, review_author, completion_source): state_changed = True if author_is_triage: if satisfy_mandatory_approver_requirement(bot, state, issue_number, review_author): diff --git a/tests/contract/reviewer_bot/test_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py index cdcd42914..d7ccc490d 100644 --- a/tests/contract/reviewer_bot/test_review_state_contract.py +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -90,6 +90,9 @@ def test_production_modules_do_not_import_mutable_review_state_api_from_reviews_ review_state_text = _read("scripts/reviewer_bot_lib/review_state.py") runtime_text = _read("scripts/reviewer_bot_lib/runtime.py") bootstrap_text = _read("scripts/reviewer_bot_lib/bootstrap_runtime.py") + commands_text = _read("scripts/reviewer_bot_lib/commands.py") + reconcile_text = _read("scripts/reviewer_bot_lib/reconcile.py") + reviews_text = _read("scripts/reviewer_bot_lib/reviews.py") for name in [ "ensure_review_entry", @@ -105,13 +108,20 @@ def test_production_modules_do_not_import_mutable_review_state_api_from_reviews_ assert f"from .reviews import {name}" not in runtime_text assert f"reviews.{name}(" not in runtime_text assert f"reviews.{name}(" not in bootstrap_text + assert f"bot.{name}(" not in commands_text + assert f"bot.{name}(" not in reconcile_text + assert f"bot.{name}(" not in reviews_text -def test_reviews_module_still_contains_known_transitional_mutation_helpers_only_as_debt_markers(): +def test_reviews_module_no_longer_exposes_public_mutation_helpers(): for name in [ "ensure_review_entry", + "accept_channel_event", + "record_reviewer_activity", + "record_transition_notice_sent", "set_current_reviewer", "update_reviewer_activity", "mark_review_complete", + "get_current_cycle_boundary", ]: - assert hasattr(reviews, name) + assert hasattr(reviews, name) is False From 8726e79e1d26f6713e63885aa8528b17b6f40d40 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:42:18 +0900 Subject: [PATCH 111/209] refactor(reviewer-bot): close review-state ownership gaps --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 1 + scripts/reviewer_bot_lib/runtime.py | 3 ++ .../test_fake_runtime_contract.py | 22 ++++++++++++++ .../test_review_state_contract.py | 29 +++++++++++++++++++ tests/fixtures/fake_runtime.py | 12 -------- tests/unit/reviewer_bot/test_commands.py | 5 ++-- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index dd62b4344..6aa20ce3d 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -133,6 +133,7 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> runtime, issue_number, comment_author ), get_commands_help=config.get_commands_help, + # Adapter-only mutable review-state compatibility surface. ensure_review_entry=lambda current_state, issue_number, create=False: review_state.ensure_review_entry( current_state, issue_number, create=create ), diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index feff7f7b8..0c553bff3 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -310,6 +310,9 @@ def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author def get_commands_help(self) -> str: return self.adapters.get_commands_help() + # Adapter-only mutable review-state compatibility surface. + # Ownership lives in review_state.py; these methods remain so runtime-oriented + # callers and test doubles can delegate through one adapter seam. def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): return self.adapters.ensure_review_entry(state, issue_number, create=create) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 65def654f..8effa3ae4 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -123,6 +123,28 @@ def test_fake_runtime_mutable_review_state_gateways_delegate_to_review_state_own assert runtime.mark_review_complete.__func__.__module__ == FakeReviewerBotRuntime.__module__ +def test_fake_runtime_closed_mutable_review_state_compatibility_surface(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + allowed = { + "ensure_review_entry", + "set_current_reviewer", + "update_reviewer_activity", + "mark_review_complete", + } + removed = { + "record_transition_notice_sent", + "accept_channel_event", + "record_reviewer_activity", + "get_current_cycle_boundary", + } + + for name in allowed: + assert hasattr(runtime, name) + for name in removed: + assert hasattr(runtime, name) is False + + def test_fake_runtime_rejects_unknown_handler_names(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) diff --git a/tests/contract/reviewer_bot/test_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py index d7ccc490d..9bfb1df6f 100644 --- a/tests/contract/reviewer_bot/test_review_state_contract.py +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -113,6 +113,35 @@ def test_production_modules_do_not_import_mutable_review_state_api_from_reviews_ assert f"bot.{name}(" not in reviews_text +def test_runtime_and_bootstrap_forwarders_are_explicit_adapter_compatibility_surface_only(): + runtime_text = _read("scripts/reviewer_bot_lib/runtime.py") + bootstrap_text = _read("scripts/reviewer_bot_lib/bootstrap_runtime.py") + + assert "Adapter-only mutable review-state compatibility surface." in runtime_text + assert "Adapter-only mutable review-state compatibility surface." in bootstrap_text + assert "review_state.ensure_review_entry(" in bootstrap_text + assert "review_state.set_current_reviewer(" in bootstrap_text + assert "review_state.update_reviewer_activity(" in bootstrap_text + assert "review_state.mark_review_complete(" in bootstrap_text + assert "record_transition_notice_sent" not in runtime_text + assert "accept_channel_event" not in runtime_text + assert "get_current_cycle_boundary" not in runtime_text + assert "record_transition_notice_sent" not in bootstrap_text + assert "accept_channel_event" not in bootstrap_text + assert "get_current_cycle_boundary" not in bootstrap_text + + +def test_tests_do_not_rely_on_runtime_mutable_review_state_forwarders_outside_contract_surface(): + for path in ROOT.glob("tests/**/*.py"): + if path.name in {"test_fake_runtime_contract.py", "test_adapter_contract.py", "test_review_state_contract.py"}: + continue + text = path.read_text(encoding="utf-8") + assert "runtime.ensure_review_entry" not in text + assert "runtime.set_current_reviewer" not in text + assert "runtime.update_reviewer_activity" not in text + assert "runtime.mark_review_complete" not in text + + def test_reviews_module_no_longer_exposes_public_mutation_helpers(): for name in [ "ensure_review_entry", diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 2ced85fd0..736181f1d 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -345,18 +345,6 @@ def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | N def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: return review_state_module.update_reviewer_activity(state, issue_number, reviewer) - def record_transition_notice_sent(self, review_data: dict, timestamp: str) -> None: - return review_state_module.record_transition_notice_sent(review_data, timestamp) - - def accept_channel_event(self, review_data: dict, channel_name: str, **kwargs) -> bool: - return review_state_module.accept_channel_event(review_data, channel_name, **kwargs) - - def record_reviewer_activity(self, review_data: dict, timestamp: str) -> bool: - return review_state_module.record_reviewer_activity(review_data, timestamp) - - def get_current_cycle_boundary(self, review_data: dict): - return review_state_module.get_current_cycle_boundary(self, review_data) - def accept_reviewer_review_from_live_review(self, review_data: dict, review: dict, *, actor: str | None = None) -> bool: return review_state_module.accept_reviewer_review_from_live_review(review_data, review, actor=actor) diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index db7462365..a5e68b84b 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -5,6 +5,7 @@ automation, comment_application, guidance, + reconcile, review_state, ) from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL @@ -269,7 +270,7 @@ def test_assign_from_queue_command_fails_closed_when_assignees_unavailable(monke def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): state = make_state() harness = CommandHarness(monkeypatch) - harness.runtime.ensure_review_entry = lambda current, issue_number, create=False: None + monkeypatch.setattr(reconcile, "ensure_review_entry", lambda current, issue_number, create=False: None) harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" message, success, changed = harness.handle_rectify(state, 42, "alice") @@ -282,7 +283,7 @@ def test_handle_rectify_command_reports_permission_unavailable(monkeypatch): def test_handle_rectify_command_reports_permission_denied(monkeypatch): state = make_state() harness = CommandHarness(monkeypatch) - harness.runtime.ensure_review_entry = lambda current, issue_number, create=False: None + monkeypatch.setattr(reconcile, "ensure_review_entry", lambda current, issue_number, create=False: None) harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "denied" message, success, changed = harness.handle_rectify(state, 42, "alice") From 364d5bdabc56df252a4f799006ac29332ae91e8f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:51:20 +0900 Subject: [PATCH 112/209] refactor(reviewer-bot): remove privileged command env hydration --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 4 +- scripts/reviewer_bot_lib/context.py | 7 ++- scripts/reviewer_bot_lib/maintenance.py | 34 ++++++++++++-- scripts/reviewer_bot_lib/runtime.py | 4 +- tests/fixtures/fake_runtime.py | 4 +- .../test_reconcile_privileged_command.py | 46 +++++++++++++++---- 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 6aa20ce3d..34dfccfb1 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -129,8 +129,8 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> handle_assign_from_queue_command=lambda current_state, issue_number, request=None: commands.handle_assign_from_queue_command( runtime, current_state, issue_number, request=request ), - handle_accept_no_fls_changes_command=lambda issue_number, comment_author: automation.handle_accept_no_fls_changes_command( - runtime, issue_number, comment_author + handle_accept_no_fls_changes_command=lambda issue_number, comment_author, request=None: automation.handle_accept_no_fls_changes_command( + runtime, issue_number, comment_author, request=request ), get_commands_help=config.get_commands_help, # Adapter-only mutable review-state compatibility surface. diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index f575fe2ff..4cf852c0a 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -338,5 +338,10 @@ def handle_release_command(self, state: dict, issue_number: int, comment_author: def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): ... def handle_assign_command(self, state: dict, issue_number: int, username: str, request: AssignmentRequest | None = None): ... def handle_assign_from_queue_command(self, state: dict, issue_number: int, request: AssignmentRequest | None = None): ... - def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): ... + def handle_accept_no_fls_changes_command( + self, + issue_number: int, + comment_author: str, + request: PrivilegedCommandRequest | None = None, + ): ... def get_commands_help(self) -> str: ... diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index f21eaf8f1..3b53791c2 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -2,11 +2,11 @@ from __future__ import annotations -import json import os import yaml +from .context import PrivilegedCommandRequest from .lifecycle import maybe_record_head_observation_repair from .overdue import ( backfill_transition_notice_if_present, @@ -29,6 +29,28 @@ def _now_iso(bot) -> str: return bot.datetime.now(bot.timezone.utc).isoformat() +def build_revalidated_privileged_command_request( + bot, + *, + issue_number: int, + actor: str, + command_name: str, + labels: set[str], +) -> PrivilegedCommandRequest: + workflow_run_reconcile_pr_number = bot.get_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() + return PrivilegedCommandRequest( + issue_number=issue_number, + actor=actor, + command_name=command_name, + is_pull_request=False, + issue_labels=tuple(sorted(labels)), + target_repo_root=bot.get_config_value("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip(), + workflow_run_reconcile_pr_number=int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None, + workflow_run_reconcile_head_sha=bot.get_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), + workflow_run_head_sha=bot.get_config_value("WORKFLOW_RUN_HEAD_SHA", "").strip(), + ) + + def _clear_maintenance_repair_marker(review_data: dict, phase: str) -> bool: marker = review_data.get("repair_needed") if not isinstance(marker, dict): @@ -189,8 +211,14 @@ def handle_manual_dispatch(bot, state: dict) -> bool: record["completed_at"] = _now_iso(bot) record["result"] = "live_revalidation_failed" return True - os.environ["ISSUE_LABELS"] = json.dumps(sorted(labels)) - message, success = bot.handle_accept_no_fls_changes_command(issue_number, actor) + request = build_revalidated_privileged_command_request( + bot, + issue_number=issue_number, + actor=actor, + command_name=str(command_name), + labels=labels, + ) + message, success = bot.handle_accept_no_fls_changes_command(issue_number, actor, request=request) record["completed_at"] = _now_iso(bot) record["result_message"] = message record["status"] = "executed" if success else "failed_closed" diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 0c553bff3..b9908d307 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -304,8 +304,8 @@ def handle_assign_command(self, state: dict, issue_number: int, username: str, r def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): return self.adapters.handle_assign_from_queue_command(state, issue_number, request=request) - def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): - return self.adapters.handle_accept_no_fls_changes_command(issue_number, comment_author) + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): + return self.adapters.handle_accept_no_fls_changes_command(issue_number, comment_author, request=request) def get_commands_help(self) -> str: return self.adapters.get_commands_help() diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 736181f1d..f1fd621e3 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -449,8 +449,8 @@ def handle_assign_command(self, state: dict, issue_number: int, username: str, r def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): return commands_module.handle_assign_from_queue_command(self, state, issue_number, request=request) - def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str): - return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author) + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): + return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author, request=request) def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): return reconcile_module.handle_rectify_command(self, state, issue_number, comment_author) diff --git a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py index f484efe84..b4398e12b 100644 --- a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -1,4 +1,3 @@ -import json import os import pytest @@ -26,12 +25,12 @@ def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): harness.set_manual_dispatch(source_event_key="issue_comment:100") harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" - harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor: ("ok", True) + harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: ("ok", True) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" -def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(monkeypatch): +def test_execute_pending_privileged_command_passes_revalidated_typed_request(monkeypatch): harness = CommandHarness(monkeypatch) state = make_state() review = review_state.ensure_review_entry(state, 42, create=True) @@ -44,27 +43,54 @@ def test_execute_pending_privileged_command_hydrates_issue_labels_for_executor(m "status": "pending", } harness.set_manual_dispatch(source_event_key="issue_comment:100") - monkeypatch.delenv("ISSUE_LABELS", raising=False) + monkeypatch.setenv("ISSUE_LABELS", '["stale-label"]') harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" observed = {} - def fake_handle(issue_number, actor): + def fake_handle(issue_number, actor, request=None): observed["issue_number"] = issue_number observed["actor"] = actor - observed["issue_labels"] = json.loads(os.environ["ISSUE_LABELS"]) + observed["request"] = request return ("ok", True) harness.runtime.handle_accept_no_fls_changes_command = fake_handle assert maintenance.handle_manual_dispatch(harness.runtime, state) is True - assert observed == { + request = observed["request"] + assert observed["issue_number"] == 42 + assert observed["actor"] == "alice" + assert request is not None + assert request.issue_number == 42 + assert request.actor == "alice" + assert request.command_name == "accept-no-fls-changes" + assert request.is_pull_request is False + assert request.issue_labels == (FLS_AUDIT_LABEL,) + assert os.environ["ISSUE_LABELS"] == '["stale-label"]' + assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + + +def test_execute_pending_privileged_command_does_not_leak_issue_labels_env(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["pending_privileged_commands"]["issue_comment:100"] = { + "source_event_key": "issue_comment:100", + "command_name": "accept-no-fls-changes", "issue_number": 42, "actor": "alice", - "issue_labels": [FLS_AUDIT_LABEL], + "status": "pending", } - assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" + harness.set_manual_dispatch(source_event_key="issue_comment:100") + monkeypatch.delenv("ISSUE_LABELS", raising=False) + harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" + harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: ("ok", True) + + assert maintenance.handle_manual_dispatch(harness.runtime, state) is True + assert "ISSUE_LABELS" not in os.environ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(monkeypatch): harness = CommandHarness(monkeypatch) @@ -82,7 +108,7 @@ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_ harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" called = {"handle": 0} - harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor: called.__setitem__("handle", called["handle"] + 1) or ("ok", True) + harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: called.__setitem__("handle", called["handle"] + 1) or ("ok", True) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert called["handle"] == 0 From 99d41d19a032b93fe6febdbf4a131733a097cca4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 05:57:49 +0900 Subject: [PATCH 113/209] refactor(reviewer-bot): centralize lifecycle event decoding --- scripts/reviewer_bot_lib/context.py | 37 +++++++++++++ scripts/reviewer_bot_lib/event_inputs.py | 45 ++++++++++++++++ scripts/reviewer_bot_lib/lifecycle.py | 67 +++++++++++++----------- scripts/reviewer_bot_lib/maintenance.py | 13 +++-- 4 files changed, 124 insertions(+), 38 deletions(-) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 4cf852c0a..fa5bc0bc7 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -96,6 +96,43 @@ class PrivilegedCommandRequest: workflow_run_head_sha: str = "" +@dataclass(frozen=True) +class ManualDispatchRequest: + action: str = "" + issue_number: int | None = None + privileged_source_event_key: str = "" + + +@dataclass(frozen=True) +class IssueLifecycleRequest: + issue_number: int = 0 + is_pull_request: bool = False + issue_labels: tuple[str, ...] = () + issue_author: str = "" + sender_login: str = "" + updated_at: str = "" + issue_title: str = "" + issue_body: str = "" + previous_title: str = "" + previous_body: str = "" + pr_head_sha: str = "" + event_created_at: str = "" + + +@dataclass(frozen=True) +class LabelEventRequest: + issue_number: int = 0 + is_pull_request: bool = False + label_name: str = "" + + +@dataclass(frozen=True) +class PullRequestSyncRequest: + issue_number: int = 0 + head_sha: str = "" + event_created_at: str = "" + + @runtime_checkable class AppEventContextRuntime(Protocol): EVENT_INTENT_MUTATING: str diff --git a/scripts/reviewer_bot_lib/event_inputs.py b/scripts/reviewer_bot_lib/event_inputs.py index d1507fcc1..628d05d5f 100644 --- a/scripts/reviewer_bot_lib/event_inputs.py +++ b/scripts/reviewer_bot_lib/event_inputs.py @@ -11,8 +11,12 @@ AssignmentRequest, CommentEventRequest, EventContext, + IssueLifecycleRequest, + LabelEventRequest, + ManualDispatchRequest, PrCommentTrustContext, PrivilegedCommandRequest, + PullRequestSyncRequest, ) @@ -144,3 +148,44 @@ def build_privileged_command_request(*, issue_number: int, actor: str = "", comm workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), ) + + +def build_manual_dispatch_request(bot: AppEventContextRuntime) -> ManualDispatchRequest: + return ManualDispatchRequest( + action=bot.get_config_value("MANUAL_ACTION").strip(), + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")), + privileged_source_event_key=bot.get_config_value("PRIVILEGED_SOURCE_EVENT_KEY").strip(), + ) + + +def build_issue_lifecycle_request(bot: AppEventContextRuntime) -> IssueLifecycleRequest: + return IssueLifecycleRequest( + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, + is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), + issue_labels=_parse_labels(bot.get_config_value("ISSUE_LABELS")), + issue_author=bot.get_config_value("ISSUE_AUTHOR").strip(), + sender_login=bot.get_config_value("SENDER_LOGIN").strip(), + updated_at=bot.get_config_value("ISSUE_UPDATED_AT").strip(), + issue_title=bot.get_config_value("ISSUE_TITLE"), + issue_body=bot.get_config_value("ISSUE_BODY"), + previous_title=bot.get_config_value("ISSUE_CHANGES_TITLE_FROM"), + previous_body=bot.get_config_value("ISSUE_CHANGES_BODY_FROM"), + pr_head_sha=bot.get_config_value("PR_HEAD_SHA").strip(), + event_created_at=bot.get_config_value("EVENT_CREATED_AT").strip(), + ) + + +def build_label_event_request(bot: AppEventContextRuntime) -> LabelEventRequest: + return LabelEventRequest( + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, + is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), + label_name=bot.get_config_value("LABEL_NAME"), + ) + + +def build_pull_request_sync_request(bot: AppEventContextRuntime) -> PullRequestSyncRequest: + return PullRequestSyncRequest( + issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, + head_sha=bot.get_config_value("PR_HEAD_SHA").strip(), + event_created_at=bot.get_config_value("EVENT_CREATED_AT").strip(), + ) diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 50db5c047..949c7bd17 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -3,8 +3,6 @@ from __future__ import annotations import hashlib -import json -import os from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timezone @@ -39,10 +37,6 @@ def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" -def _is_pr_event() -> bool: - return os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" - - def _normalize_comment_body(body: str) -> str: return "\n".join(line.rstrip() for line in body.replace("\r\n", "\n").split("\n")).strip() @@ -77,7 +71,10 @@ def handle_transition_notice(bot, state: dict, issue_number: int, reviewer: str) def handle_issue_or_pr_opened(bot, state: dict) -> bool: bot.assert_lock_held("handle_issue_or_pr_opened") - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + from .event_inputs import build_issue_lifecycle_request + + request = build_issue_lifecycle_request(bot) + issue_number = request.issue_number if not issue_number: return False bot.collect_touched_item(issue_number) @@ -94,24 +91,20 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: raise RuntimeError(f"Unable to determine assignees for #{issue_number}") if current_assignees: return False - labels_json = os.environ.get("ISSUE_LABELS", "[]") - try: - labels = json.loads(labels_json) - except json.JSONDecodeError: - labels = [] + labels = list(request.issue_labels) if not any(label in bot.REVIEW_LABELS for label in labels): return False - issue_author = os.environ.get("ISSUE_AUTHOR", "") + issue_author = request.issue_author reviewer = bot.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) if not reviewer: bot.post_comment(issue_number, f"⚠️ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue.") return False - is_pr = _is_pr_event() + is_pr = request.is_pull_request assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) set_current_reviewer(state, issue_number, reviewer) review_data = ensure_review_entry(state, issue_number, create=True) if is_pr and isinstance(review_data, dict): - head_sha = os.environ.get("PR_HEAD_SHA", "").strip() + head_sha = request.pr_head_sha if head_sha: review_data["active_head_sha"] = head_sha bot.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") @@ -128,24 +121,27 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: def handle_issue_edited_event(bot, state: dict) -> bool: bot.assert_lock_held("handle_issue_edited_event") - if _is_pr_event(): + from .event_inputs import build_issue_lifecycle_request + + request = build_issue_lifecycle_request(bot) + if request.is_pull_request: return False - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + issue_number = request.issue_number if not issue_number: return False bot.collect_touched_item(issue_number) - issue_author = os.environ.get("ISSUE_AUTHOR", "").strip() - editor = os.environ.get("SENDER_LOGIN", "").strip() or issue_author + issue_author = request.issue_author + editor = request.sender_login or issue_author if not issue_author or editor.lower() != issue_author.lower(): return False review_data = ensure_review_entry(state, issue_number) if review_data is None: return False - updated_at = os.environ.get("ISSUE_UPDATED_AT", "").strip() or _now_iso() - current_title = os.environ.get("ISSUE_TITLE", "") - current_body = os.environ.get("ISSUE_BODY", "") - previous_title = os.environ.get("ISSUE_CHANGES_TITLE_FROM", "") - previous_body = os.environ.get("ISSUE_CHANGES_BODY_FROM", "") + updated_at = request.updated_at or _now_iso() + current_title = request.issue_title + current_body = request.issue_body + previous_title = request.previous_title + previous_body = request.previous_body title_changed = _normalize_comment_body(current_title) != _normalize_comment_body(previous_title) body_changed = _normalize_comment_body(current_body) != _normalize_comment_body(previous_body) if not title_changed and not body_changed: @@ -168,11 +164,14 @@ def handle_issue_edited_event(bot, state: dict) -> bool: def handle_labeled_event(bot, state: dict) -> bool: bot.assert_lock_held("handle_labeled_event") - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + from .event_inputs import build_label_event_request + + request = build_label_event_request(bot) + issue_number = request.issue_number if not issue_number: return False - label_name = os.environ.get("LABEL_NAME", "") - is_pr = _is_pr_event() + label_name = request.label_name + is_pr = request.is_pull_request bot.collect_touched_item(issue_number) if label_name == "sign-off: create pr": if is_pr: @@ -187,16 +186,19 @@ def handle_labeled_event(bot, state: dict) -> bool: def handle_pull_request_target_synchronize(bot, state: dict) -> bool: bot.assert_lock_held("handle_pull_request_target_synchronize") + from .event_inputs import build_pull_request_sync_request + if _runtime_epoch(state) != "freshness_v15": print("V18 synchronize repair safe-noop before epoch flip") return False - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + request = build_pull_request_sync_request(bot) + issue_number = request.issue_number if not issue_number: return False review_data = ensure_review_entry(state, issue_number) if review_data is None or not review_data.get("current_reviewer"): return False - head_sha = os.environ.get("PR_HEAD_SHA", "").strip() + head_sha = request.head_sha if not head_sha: raise RuntimeError("Missing PR_HEAD_SHA for synchronize event") bot.collect_touched_item(issue_number) @@ -207,7 +209,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: previous_review_completed_by = review_data.get("review_completed_by") previous_review_completion_source = review_data.get("review_completion_source") review_data["active_head_sha"] = head_sha - timestamp = os.environ.get("EVENT_CREATED_AT", "") or _now_iso() + timestamp = request.event_created_at or _now_iso() changed = accept_channel_event( review_data, "contributor_revision", @@ -309,7 +311,10 @@ def maybe_record_head_observation_repair(bot, issue_number: int, review_data: di def handle_closed_event(bot, state: dict) -> bool: bot.assert_lock_held("handle_closed_event") - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + from .event_inputs import build_issue_lifecycle_request + + request = build_issue_lifecycle_request(bot) + issue_number = request.issue_number if not issue_number: return False bot.collect_touched_item(issue_number) diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 3b53791c2..101c36a4a 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -2,11 +2,10 @@ from __future__ import annotations -import os - import yaml from .context import PrivilegedCommandRequest +from .event_inputs import build_manual_dispatch_request from .lifecycle import maybe_record_head_observation_repair from .overdue import ( backfill_transition_notice_if_present, @@ -107,7 +106,8 @@ def collect_status_projection_repair_items(bot, state: dict) -> list[int]: def handle_manual_dispatch(bot, state: dict) -> bool: - action = os.environ.get("MANUAL_ACTION", "") + request = build_manual_dispatch_request(bot) + action = request.action if action == "show-state": print(f"Current state:\n{yaml.dump(state, default_flow_style=False)}") return False @@ -121,10 +121,9 @@ def handle_manual_dispatch(bot, state: dict) -> bool: "Reviewer board preview preflight failed: " + "; ".join(preflight.errors) ) - issue_number_raw = os.environ.get("ISSUE_NUMBER", "").strip() issue_numbers: list[int] = [] - if issue_number_raw: - issue_numbers = [int(issue_number_raw)] + if request.issue_number: + issue_numbers = [request.issue_number] else: active_reviews = state.get("active_reviews") if isinstance(active_reviews, dict): @@ -160,7 +159,7 @@ def handle_manual_dispatch(bot, state: dict) -> bool: if action == "check-overdue": return bot.handle_scheduled_check(state) if action == "execute-pending-privileged-command": - source_event_key = os.environ.get("PRIVILEGED_SOURCE_EVENT_KEY", "").strip() + source_event_key = request.privileged_source_event_key if not source_event_key: raise RuntimeError("Missing PRIVILEGED_SOURCE_EVENT_KEY for privileged command execution") for issue_key, review_data in (state.get("active_reviews") or {}).items(): From 81b0707363bc3cd3c862d47d715797678cb04a7b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 06:01:18 +0900 Subject: [PATCH 114/209] refactor(reviewer-bot): type lifecycle event inputs --- .../reviewer_bot/test_adapter_contract.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 1fe7eda2d..9b268990d 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.contract from scripts import reviewer_bot -from scripts.reviewer_bot_lib import lease_lock, review_state +from scripts.reviewer_bot_lib import event_inputs, lease_lock, review_state from scripts.reviewer_bot_lib.context import ( AppEventContextRuntime, AppExecutionRuntime, @@ -84,3 +84,66 @@ def test_runtime_review_state_adapter_mutates_active_reviews(): review = reviewer_bot._runtime_bot().ensure_review_entry(state, 42, create=True) assert review is state["active_reviews"]["42"] + + +def test_event_inputs_build_manual_dispatch_request_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("MANUAL_ACTION", "preview-reviewer-board") + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("PRIVILEGED_SOURCE_EVENT_KEY", "issue_comment:100") + + request = event_inputs.build_manual_dispatch_request(runtime) + + assert request.action == "preview-reviewer-board" + assert request.issue_number == 42 + assert request.privileged_source_event_key == "issue_comment:100" + + +def test_event_inputs_build_issue_lifecycle_request_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.set_config_value("ISSUE_LABELS", '["coding guideline"]') + runtime.set_config_value("ISSUE_AUTHOR", "dana") + runtime.set_config_value("SENDER_LOGIN", "alice") + runtime.set_config_value("ISSUE_UPDATED_AT", "2026-03-17T10:00:00Z") + runtime.set_config_value("ISSUE_TITLE", "New title") + runtime.set_config_value("ISSUE_BODY", "new body") + runtime.set_config_value("ISSUE_CHANGES_TITLE_FROM", "Old title") + runtime.set_config_value("ISSUE_CHANGES_BODY_FROM", "old body") + runtime.set_config_value("PR_HEAD_SHA", "head-2") + runtime.set_config_value("EVENT_CREATED_AT", "2026-03-17T10:05:00Z") + + request = event_inputs.build_issue_lifecycle_request(runtime) + + assert request.issue_number == 42 + assert request.is_pull_request is True + assert request.issue_labels == ("coding guideline",) + assert request.issue_author == "dana" + assert request.sender_login == "alice" + assert request.updated_at == "2026-03-17T10:00:00Z" + assert request.issue_title == "New title" + assert request.issue_body == "new body" + assert request.previous_title == "Old title" + assert request.previous_body == "old body" + assert request.pr_head_sha == "head-2" + assert request.event_created_at == "2026-03-17T10:05:00Z" + + +def test_event_inputs_build_label_and_sync_requests_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.set_config_value("LABEL_NAME", "sign-off: create pr") + runtime.set_config_value("PR_HEAD_SHA", "head-2") + runtime.set_config_value("EVENT_CREATED_AT", "2026-03-17T10:05:00Z") + + label_request = event_inputs.build_label_event_request(runtime) + sync_request = event_inputs.build_pull_request_sync_request(runtime) + + assert label_request.issue_number == 42 + assert label_request.is_pull_request is True + assert label_request.label_name == "sign-off: create pr" + assert sync_request.issue_number == 42 + assert sync_request.head_sha == "head-2" + assert sync_request.event_created_at == "2026-03-17T10:05:00Z" From a23ddb3b564b209891511cddb8f3927969368f04 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 06:05:40 +0900 Subject: [PATCH 115/209] refactor(reviewer-bot): clarify reconcile replay scenarios --- tests/fixtures/reconcile_harness.py | 16 ++++++++ .../test_reconcile_workflow_run.py | 39 +++++++------------ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index af7db2406..a18997a3a 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -282,5 +282,21 @@ def stub_review_rebuild(self, *, changed: bool = False) -> None: lambda bot, state_obj, issue_number, review_data: changed, ) + def stub_comment_classification(self, classified: dict | None = None, *, func=None) -> None: + if func is None: + def default_func(bot, body): + return classified + + func = default_func + self.monkeypatch.setattr(reconcile, "classify_comment_payload", func) + + def stub_apply_comment_command(self, result: bool | None = None, *, func=None) -> None: + if func is None: + def default_func(*args, **kwargs): + return result + + func = default_func + self.monkeypatch.setattr(comment_application, "apply_comment_command", func) + def run(self, state: dict) -> bool: return self.handle_workflow_run_event(state) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index 475a472cd..adffb9e93 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -1,6 +1,5 @@ import pytest -from scripts.reviewer_bot_lib import comment_application, reconcile from tests.fixtures.reconcile_harness import ( ReconcileHarness, issue_comment_payload, @@ -113,7 +112,7 @@ def test_deferred_comment_reconcile_returns_true_for_bookkeeping_only_mutations( author_type="User", author_association="MEMBER", ) - monkeypatch.setattr(comment_application, "apply_comment_command", lambda *args, **kwargs: False) + harness.stub_apply_comment_command(False) assert harness.run(state) is True assert "issue_comment:210" in review["reconciled_source_events"] @@ -407,24 +406,18 @@ def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguou author_type="User", author_association="MEMBER", ) - monkeypatch.setattr( - reconcile, - "classify_comment_payload", - lambda bot, body: { + harness.stub_comment_classification( + { "comment_class": "command_only", "has_non_command_text": False, "command_count": 2, "command": None, "args": [], - "normalized_body": body, - }, + "normalized_body": live_body, + } ) command_calls = [] - monkeypatch.setattr( - comment_application, - "apply_comment_command", - lambda *args, **kwargs: command_calls.append("called") or True, - ) + harness.stub_apply_comment_command(func=lambda *args, **kwargs: command_calls.append("called") or True) assert harness.run(state) is True assert command_calls == [] @@ -501,17 +494,17 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke ) harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" claim_contexts = [] - monkeypatch.setattr( - comment_application, - "apply_comment_command", - lambda bot, state_obj, request, classified, classify_issue_comment_actor=None: claim_contexts.append( + harness.stub_apply_comment_command( + func=lambda bot, state_obj, request, classified, classify_issue_comment_actor=None: claim_contexts.append( { "issue_number": request.issue_number, "username": request.comment_author, "is_pull_request": request.is_pull_request, "issue_author": request.issue_author, } - ) or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) or True, + ) + or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) + or True ) harness.runtime.add_reaction = lambda *args, **kwargs: True @@ -587,17 +580,15 @@ def test_deferred_comment_reconcile_fails_closed_when_comment_classification_dri author_type="User", author_association="CONTRIBUTOR", ) - monkeypatch.setattr( - reconcile, - "classify_comment_payload", - lambda bot, body: { + harness.stub_comment_classification( + { "comment_class": "command_plus_text", "has_non_command_text": True, "command_count": 1, "command": "claim", "args": [], - "normalized_body": body, - }, + "normalized_body": live_body, + } ) assert harness.run(state) is True From 299955d234c363e78c71e014dc631a0fc71c9b23 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 06:08:58 +0900 Subject: [PATCH 116/209] refactor(reviewer-bot): polish reconcile workflow tests --- .../test_reconcile_workflow_run.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py index adffb9e93..b43efe46d 100644 --- a/tests/integration/reviewer_bot/test_reconcile_workflow_run.py +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -417,7 +417,12 @@ def test_deferred_comment_reconcile_fails_closed_when_command_replay_is_ambiguou } ) command_calls = [] - harness.stub_apply_comment_command(func=lambda *args, **kwargs: command_calls.append("called") or True) + + def record_command_call(*args, **kwargs): + command_calls.append("called") + return True + + harness.stub_apply_comment_command(func=record_command_call) assert harness.run(state) is True assert command_calls == [] @@ -494,8 +499,9 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke ) harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" claim_contexts = [] - harness.stub_apply_comment_command( - func=lambda bot, state_obj, request, classified, classify_issue_comment_actor=None: claim_contexts.append( + + def apply_claim_command(bot, state_obj, request, classified, classify_issue_comment_actor=None): + claim_contexts.append( { "issue_number": request.issue_number, "username": request.comment_author, @@ -503,9 +509,10 @@ def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monke "issue_author": request.issue_author, } ) - or state_obj["active_reviews"][str(request.issue_number)].__setitem__("current_reviewer", request.comment_author) - or True - ) + state_obj["active_reviews"][str(request.issue_number)]["current_reviewer"] = request.comment_author + return True + + harness.stub_apply_comment_command(func=apply_claim_command) harness.runtime.add_reaction = lambda *args, **kwargs: True assert harness.run(state) is True From c747a60461c296b7e7075ac3620d98907933fdc1 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 07:16:20 +0900 Subject: [PATCH 117/209] refactor(reviewer-bot): add narrow protocol foundations --- scripts/reviewer_bot_lib/context.py | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index fa5bc0bc7..ceda205fc 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -133,6 +133,75 @@ class PullRequestSyncRequest: event_created_at: str = "" +@runtime_checkable +class ConfigProvider(Protocol): + def get(self, name: str, default: str = "") -> str: ... + + def set(self, name: str, value: Any) -> None: ... + + +@runtime_checkable +class Clock(Protocol): + def now(self) -> datetime: ... + + +@runtime_checkable +class Sleeper(Protocol): + def sleep(self, seconds: float) -> None: ... + + +@runtime_checkable +class JitterSource(Protocol): + def uniform(self, lower: float, upper: float) -> float: ... + + +@runtime_checkable +class UuidSource(Protocol): + def uuid4_hex(self) -> str: ... + + +@runtime_checkable +class Logger(Protocol): + def event(self, level: str, message: str, **fields: Any) -> None: ... + + +@runtime_checkable +class RestTransport(Protocol): + def request( + self, + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + json_data: dict[str, Any] | None = None, + timeout_seconds: float | None = None, + ) -> Any: ... + + +@runtime_checkable +class GraphQLTransport(Protocol): + def query( + self, + url: str, + *, + headers: dict[str, str] | None = None, + query: str, + variables: dict[str, Any] | None = None, + timeout_seconds: float | None = None, + ) -> Any: ... + + +@runtime_checkable +class ArtifactDownloadTransport(Protocol): + def download( + self, + url: str, + *, + headers: dict[str, str] | None = None, + timeout_seconds: float | None = None, + ) -> Any: ... + + @runtime_checkable class AppEventContextRuntime(Protocol): EVENT_INTENT_MUTATING: str From 6151599db73dc8133526997c1bf8570a4fbd83a9 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 07:17:42 +0900 Subject: [PATCH 118/209] test(reviewer-bot): separate runtime protocol compatibility checks --- .../reviewer_bot/test_adapter_contract.py | 28 +----------------- .../reviewer_bot/test_runtime_protocols.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 tests/contract/reviewer_bot/test_runtime_protocols.py diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 9b268990d..e129f8f10 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -6,42 +6,16 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import event_inputs, lease_lock, review_state -from scripts.reviewer_bot_lib.context import ( - AppEventContextRuntime, - AppExecutionRuntime, - ReviewerBotContext, -) -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from scripts.reviewer_bot_lib.context import ReviewerBotContext from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state -def test_runtime_bot_returns_concrete_runtime_object(): - runtime = reviewer_bot._runtime_bot() - - assert isinstance(runtime, ReviewerBotRuntime) - assert runtime.EVENT_INTENT_MUTATING == reviewer_bot._runtime_bot().EVENT_INTENT_MUTATING - - -def test_runtime_object_satisfies_runtime_context_protocols(): - runtime = reviewer_bot._runtime_bot() - - assert isinstance(runtime, AppEventContextRuntime) - assert isinstance(runtime, AppExecutionRuntime) - - def test_render_lock_commit_message_uses_direct_json_import(): rendered = lease_lock.render_lock_commit_message(reviewer_bot._runtime_bot(), {"lock_state": "unlocked"}) assert rendered.startswith("reviewer-bot-lock-v1\n") -def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): - runtime = FakeReviewerBotRuntime(monkeypatch) - - assert isinstance(runtime, AppEventContextRuntime) - assert isinstance(runtime, AppExecutionRuntime) - - def test_build_event_context_returns_structured_context(monkeypatch): monkeypatch.setenv("EVENT_NAME", "workflow_run") monkeypatch.setenv("EVENT_ACTION", "completed") diff --git a/tests/contract/reviewer_bot/test_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py new file mode 100644 index 000000000..ea2c856dd --- /dev/null +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -0,0 +1,29 @@ +import pytest + +pytestmark = pytest.mark.contract + +from scripts import reviewer_bot +from scripts.reviewer_bot_lib.context import AppEventContextRuntime, AppExecutionRuntime +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime + + +def test_runtime_bot_returns_concrete_runtime_object(): + runtime = reviewer_bot._runtime_bot() + + assert isinstance(runtime, ReviewerBotRuntime) + assert runtime.EVENT_INTENT_MUTATING == reviewer_bot._runtime_bot().EVENT_INTENT_MUTATING + + +def test_runtime_object_satisfies_runtime_context_protocols(): + runtime = reviewer_bot._runtime_bot() + + assert isinstance(runtime, AppEventContextRuntime) + assert isinstance(runtime, AppExecutionRuntime) + + +def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert isinstance(runtime, AppEventContextRuntime) + assert isinstance(runtime, AppExecutionRuntime) From 1c78800533b611c7f22518901a47809786fc7944 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 07:28:17 +0900 Subject: [PATCH 119/209] refactor(reviewer-bot): route event input builders through runtime config --- scripts/reviewer_bot_lib/automation.py | 18 ++-- scripts/reviewer_bot_lib/bootstrap_runtime.py | 2 +- scripts/reviewer_bot_lib/commands.py | 29 +++--- scripts/reviewer_bot_lib/comment_routing.py | 28 +++--- scripts/reviewer_bot_lib/event_inputs.py | 89 ++++++++++--------- .../reviewer_bot/test_adapter_contract.py | 86 ++++++++++++++++++ tests/fixtures/commands_harness.py | 8 +- tests/fixtures/comment_routing_harness.py | 4 +- tests/fixtures/fake_runtime.py | 2 +- 9 files changed, 181 insertions(+), 85 deletions(-) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 4d154e48b..2b4786f1c 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -9,8 +9,8 @@ build_privileged_command_request as decode_privileged_command_request, ) from .event_inputs import ( - get_target_repo_root_from_env, - parse_issue_labels_env, + get_target_repo_root as decode_target_repo_root, + parse_issue_labels as decode_issue_labels, ) @@ -40,23 +40,24 @@ def list_changed_files(repo_root: Path) -> list[str]: return sorted(set(files)) -def get_target_repo_root() -> Path: - configured = get_target_repo_root_from_env() +def get_target_repo_root(bot) -> Path: + configured = decode_target_repo_root(bot) if configured is not None: return configured return Path(__file__).resolve().parents[2] -def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: +def build_privileged_command_request(bot, *, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: return decode_privileged_command_request( + bot, issue_number=issue_number, actor=actor, command_name=command_name, ) -def bot_parse_issue_labels() -> list[str]: - return parse_issue_labels_env() +def bot_parse_issue_labels(bot) -> list[str]: + return decode_issue_labels(bot) def get_default_branch(bot) -> str: @@ -123,6 +124,7 @@ def handle_accept_no_fls_changes_command( request: PrivilegedCommandRequest | None = None, ) -> tuple[str, bool]: privileged_request = request or build_privileged_command_request( + bot, issue_number=issue_number, actor=comment_author, command_name="accept-no-fls-changes", @@ -138,7 +140,7 @@ def handle_accept_no_fls_changes_command( if permission_status != "granted": return "❌ You must have triage permissions to run this command.", False - repo_root = Path(privileged_request.target_repo_root) if privileged_request.target_repo_root else get_target_repo_root() + repo_root = Path(privileged_request.target_repo_root) if privileged_request.target_repo_root else get_target_repo_root(bot) if bot.list_changed_files(repo_root): return "❌ Working tree is not clean; refusing to update spec.lock.", False diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 34dfccfb1..3f0bd4abc 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -175,7 +175,7 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> create_pull_request=lambda branch, base, issue_number: automation.create_pull_request( runtime, branch, base, issue_number ), - parse_issue_labels=automation.bot_parse_issue_labels, + parse_issue_labels=lambda: automation.bot_parse_issue_labels(runtime), normalize_lock_metadata=state_store.normalize_lock_metadata, get_state_issue=lambda: state_store.get_state_issue(runtime), clear_lock_metadata=lambda: lease_lock.clear_lock_metadata(runtime), diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 897a910c9..366979984 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -17,12 +17,13 @@ from .guidance import get_issue_guidance, get_pr_guidance -def build_assignment_request(*, issue_number: int) -> AssignmentRequest: - return decode_assignment_request(issue_number=issue_number) +def build_assignment_request(bot, *, issue_number: int) -> AssignmentRequest: + return decode_assignment_request(bot, issue_number=issue_number) -def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: +def build_privileged_command_request(bot, *, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: return decode_privileged_command_request( + bot, issue_number=issue_number, actor=actor, command_name=command_name, @@ -142,7 +143,7 @@ def handle_pass_command( reason: str | None, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) issue_data = review_state.ensure_review_entry(state, issue_number, create=True) if issue_data is None: return "❌ Unable to load review state.", False @@ -197,7 +198,7 @@ def handle_pass_until_command( reason: str | None, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) try: parsed_date = datetime.strptime(return_date, "%Y-%m-%d").date() except ValueError: @@ -276,7 +277,7 @@ def handle_label_command( label_string: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) pattern = r'(?:(?<=^)|(?<=\s))([+-])(.+?)(?=\s[+-]|\s*$)' matches = re.findall(pattern, label_string) if not matches: @@ -316,8 +317,8 @@ def handle_label_command( return "\n".join(results), all_success, state_changed -def parse_issue_labels() -> list[str]: - return bot_parse_issue_labels() +def parse_issue_labels(bot) -> list[str]: + return bot_parse_issue_labels(bot) def get_default_branch(bot) -> str: @@ -338,7 +339,7 @@ def resolve_workflow_run_pr_number( bot, request: PrivilegedCommandRequest | None = None, ) -> int: - privileged_request = request or build_privileged_command_request(issue_number=0) + privileged_request = request or decode_privileged_command_request(bot, issue_number=0) if privileged_request.workflow_run_reconcile_pr_number is None: raise RuntimeError("Missing WORKFLOW_RUN_RECONCILE_PR_NUMBER in workflow_run reconcile context") pr_number = privileged_request.workflow_run_reconcile_pr_number @@ -383,7 +384,7 @@ def handle_queue_command( state: dict, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=0) + assignment_request = request or decode_assignment_request(bot, issue_number=0) queue_size = len(state["queue"]) repo_owner = assignment_request.repo_owner repo_name = assignment_request.repo_name @@ -420,7 +421,7 @@ def handle_claim_command( comment_author: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) is_producer = any(member["github"].lower() == comment_author.lower() for member in state["queue"]) is_away = any(member["github"].lower() == comment_author.lower() for member in state.get("pass_until", [])) if not is_producer and not is_away: @@ -456,7 +457,7 @@ def handle_release_command( request: AssignmentRequest | None = None, ) -> tuple[str, bool]: args = args or [] - request = request or build_assignment_request(issue_number=issue_number) + request = request or decode_assignment_request(bot, issue_number=issue_number) target_username = None reason = None releasing_other = False @@ -515,7 +516,7 @@ def handle_assign_command( username: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) username = username.lstrip("@") if not username: return (f"❌ Missing username. Usage: `{bot.BOT_MENTION} /r? @username`"), False @@ -555,7 +556,7 @@ def handle_assign_from_queue_command( issue_number: int, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or build_assignment_request(issue_number=issue_number) + assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) if assignee_error: return assignee_error, False diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index d4856d840..d27eacd02 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -32,12 +32,12 @@ def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" -def build_comment_event_request(*, issue_number: int | None = None) -> CommentEventRequest: - return decode_comment_event_request(issue_number=issue_number) +def build_comment_event_request(bot, *, issue_number: int | None = None) -> CommentEventRequest: + return decode_comment_event_request(bot, issue_number=issue_number) -def build_pr_comment_trust_context() -> PrCommentTrustContext: - return decode_pr_comment_trust_context() +def build_pr_comment_trust_context(bot) -> PrCommentTrustContext: + return decode_pr_comment_trust_context(bot) def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) -> bool: @@ -121,7 +121,9 @@ def _classify_issue_comment_actor(request: CommentEventRequest) -> str: def classify_issue_comment_actor(request: CommentEventRequest | None = None) -> str: - return _classify_issue_comment_actor(request or build_comment_event_request()) + if request is None: + raise RuntimeError("classify_issue_comment_actor requires an explicit request or runtime-aware caller") + return _classify_issue_comment_actor(request) def _is_self_comment(bot, author: str) -> bool: @@ -168,11 +170,11 @@ def classify_pr_comment_processing_target( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> str: - comment_request = request or build_comment_event_request(issue_number=issue_number) + comment_request = request or build_comment_event_request(bot, issue_number=issue_number) return _classify_pr_comment_processing_target( bot, comment_request, - trust_context or build_pr_comment_trust_context(), + trust_context or build_pr_comment_trust_context(bot), ) @@ -199,11 +201,11 @@ def route_issue_comment_trust( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> str: - comment_request = request or build_comment_event_request(issue_number=issue_number) + comment_request = request or build_comment_event_request(bot, issue_number=issue_number) return _route_issue_comment_trust( bot, comment_request, - trust_context or build_pr_comment_trust_context(), + trust_context or build_pr_comment_trust_context(bot), ) @@ -277,11 +279,11 @@ def build_pr_comment_observer_payload( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> dict: - comment_request = request or build_comment_event_request(issue_number=issue_number) + comment_request = request or build_comment_event_request(bot, issue_number=issue_number) return _build_pr_comment_observer_payload( bot, comment_request, - trust_context or build_pr_comment_trust_context(), + trust_context or build_pr_comment_trust_context(bot), ) @@ -302,7 +304,7 @@ def handle_comment_event( trust_context: PrCommentTrustContext | None = None, ) -> bool: bot.assert_lock_held("handle_comment_event") - comment_request = request or build_comment_event_request() + comment_request = request or build_comment_event_request(bot) issue_number = comment_request.issue_number if not issue_number: return False @@ -310,7 +312,7 @@ def handle_comment_event( route = _route_issue_comment_trust( bot, comment_request, - trust_context or build_pr_comment_trust_context(), + trust_context or build_pr_comment_trust_context(bot), ) if route == "safe_noop": return False diff --git a/scripts/reviewer_bot_lib/event_inputs.py b/scripts/reviewer_bot_lib/event_inputs.py index 628d05d5f..cb02aa37d 100644 --- a/scripts/reviewer_bot_lib/event_inputs.py +++ b/scripts/reviewer_bot_lib/event_inputs.py @@ -1,9 +1,8 @@ -"""Decode env/config inputs into typed reviewer-bot request objects.""" +"""Decode runtime/config inputs into typed reviewer-bot request objects.""" from __future__ import annotations import json -import os from pathlib import Path from .context import ( @@ -54,12 +53,12 @@ def _parse_labels(value: str) -> tuple[str, ...]: return tuple(str(label) for label in payload) -def parse_issue_labels_env() -> list[str]: - return list(_parse_labels(os.environ.get("ISSUE_LABELS", "[]"))) +def parse_issue_labels(bot: AppEventContextRuntime) -> list[str]: + return list(_parse_labels(bot.get_config_value("ISSUE_LABELS", "[]"))) -def get_target_repo_root_from_env() -> Path | None: - configured = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() +def get_target_repo_root(bot: AppEventContextRuntime) -> Path | None: + configured = bot.get_config_value("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() if not configured: return None return Path(configured) @@ -93,60 +92,66 @@ def build_event_context(bot: AppEventContextRuntime) -> EventContext: ) -def build_comment_event_request(*, issue_number: int | None = None) -> CommentEventRequest: +def build_comment_event_request(bot: AppEventContextRuntime, *, issue_number: int | None = None) -> CommentEventRequest: return CommentEventRequest( - issue_number=issue_number if issue_number is not None else int(os.environ.get("ISSUE_NUMBER", "0") or 0), - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_state=os.environ.get("ISSUE_STATE", "").strip().lower(), - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - comment_id=int(os.environ.get("COMMENT_ID", "0") or 0), - comment_author=os.environ.get("COMMENT_AUTHOR", ""), - comment_author_id=int(os.environ.get("COMMENT_AUTHOR_ID", "0") or 0), - comment_body=os.environ.get("COMMENT_BODY", ""), - comment_created_at=os.environ.get("COMMENT_CREATED_AT", ""), - comment_source_event_key=os.environ.get("COMMENT_SOURCE_EVENT_KEY", "").strip(), - comment_user_type=os.environ.get("COMMENT_USER_TYPE", "").strip(), - comment_sender_type=os.environ.get("COMMENT_SENDER_TYPE", "").strip(), - comment_installation_id=os.environ.get("COMMENT_INSTALLATION_ID", "").strip(), - comment_performed_via_github_app=os.environ.get("COMMENT_PERFORMED_VIA_GITHUB_APP", "").strip().lower() == "true", + issue_number=issue_number if issue_number is not None else (_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0), + is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), + issue_state=bot.get_config_value("ISSUE_STATE").strip().lower(), + issue_author=bot.get_config_value("ISSUE_AUTHOR"), + comment_id=_parse_optional_int(bot.get_config_value("COMMENT_ID")) or 0, + comment_author=bot.get_config_value("COMMENT_AUTHOR"), + comment_author_id=_parse_optional_int(bot.get_config_value("COMMENT_AUTHOR_ID")) or 0, + comment_body=bot.get_config_value("COMMENT_BODY"), + comment_created_at=bot.get_config_value("COMMENT_CREATED_AT"), + comment_source_event_key=bot.get_config_value("COMMENT_SOURCE_EVENT_KEY").strip(), + comment_user_type=bot.get_config_value("COMMENT_USER_TYPE").strip(), + comment_sender_type=bot.get_config_value("COMMENT_SENDER_TYPE").strip(), + comment_installation_id=bot.get_config_value("COMMENT_INSTALLATION_ID").strip(), + comment_performed_via_github_app=bool(_parse_optional_bool(bot.get_config_value("COMMENT_PERFORMED_VIA_GITHUB_APP"))), ) -def build_pr_comment_trust_context() -> PrCommentTrustContext: +def build_pr_comment_trust_context(bot: AppEventContextRuntime) -> PrCommentTrustContext: return PrCommentTrustContext( - github_repository=os.environ.get("GITHUB_REPOSITORY", ""), - comment_author_association=os.environ.get("COMMENT_AUTHOR_ASSOCIATION", "").strip(), - current_workflow_file=os.environ.get("CURRENT_WORKFLOW_FILE", "").strip(), - github_ref=os.environ.get("GITHUB_REF", "").strip(), - github_run_id=int(os.environ.get("GITHUB_RUN_ID", "0") or 0), - github_run_attempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "0") or 0), + github_repository=bot.get_config_value("GITHUB_REPOSITORY"), + comment_author_association=bot.get_config_value("COMMENT_AUTHOR_ASSOCIATION").strip(), + current_workflow_file=bot.get_config_value("CURRENT_WORKFLOW_FILE").strip(), + github_ref=bot.get_config_value("GITHUB_REF").strip(), + github_run_id=_parse_optional_int(bot.get_config_value("GITHUB_RUN_ID")) or 0, + github_run_attempt=_parse_optional_int(bot.get_config_value("GITHUB_RUN_ATTEMPT")) or 0, ) -def build_assignment_request(*, issue_number: int) -> AssignmentRequest: +def build_assignment_request(bot: AppEventContextRuntime, *, issue_number: int) -> AssignmentRequest: return AssignmentRequest( issue_number=issue_number, - issue_author=os.environ.get("ISSUE_AUTHOR", ""), - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=_parse_labels(os.environ.get("ISSUE_LABELS", "[]")), - repo_owner=os.environ.get("REPO_OWNER", ""), - repo_name=os.environ.get("REPO_NAME", ""), + issue_author=bot.get_config_value("ISSUE_AUTHOR"), + is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), + issue_labels=_parse_labels(bot.get_config_value("ISSUE_LABELS", "[]")), + repo_owner=bot.get_config_value("REPO_OWNER"), + repo_name=bot.get_config_value("REPO_NAME"), ) -def build_privileged_command_request(*, issue_number: int, actor: str = "", command_name: str = "") -> PrivilegedCommandRequest: - target_repo_root = os.environ.get("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() - workflow_run_reconcile_pr_number = os.environ.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() +def build_privileged_command_request( + bot: AppEventContextRuntime, + *, + issue_number: int, + actor: str = "", + command_name: str = "", +) -> PrivilegedCommandRequest: + target_repo_root = bot.get_config_value("REVIEWER_BOT_TARGET_REPO_ROOT").strip() + workflow_run_reconcile_pr_number = bot.get_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER").strip() return PrivilegedCommandRequest( issue_number=issue_number, actor=actor, command_name=command_name, - is_pull_request=os.environ.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=_parse_labels(os.environ.get("ISSUE_LABELS", "[]")), + is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), + issue_labels=_parse_labels(bot.get_config_value("ISSUE_LABELS", "[]")), target_repo_root=target_repo_root, - workflow_run_reconcile_pr_number=int(workflow_run_reconcile_pr_number) if workflow_run_reconcile_pr_number else None, - workflow_run_reconcile_head_sha=os.environ.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "").strip(), - workflow_run_head_sha=os.environ.get("WORKFLOW_RUN_HEAD_SHA", "").strip(), + workflow_run_reconcile_pr_number=_parse_optional_int(workflow_run_reconcile_pr_number), + workflow_run_reconcile_head_sha=bot.get_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA").strip(), + workflow_run_head_sha=bot.get_config_value("WORKFLOW_RUN_HEAD_SHA").strip(), ) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index e129f8f10..e2c7e8289 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -73,6 +73,92 @@ def test_event_inputs_build_manual_dispatch_request_from_runtime_config(monkeypa assert request.privileged_source_event_key == "issue_comment:100" +def test_event_inputs_build_comment_request_and_trust_context_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.set_config_value("ISSUE_STATE", "open") + runtime.set_config_value("ISSUE_AUTHOR", "dana") + runtime.set_config_value("COMMENT_ID", "100") + runtime.set_config_value("COMMENT_AUTHOR", "alice") + runtime.set_config_value("COMMENT_AUTHOR_ID", "200") + runtime.set_config_value("COMMENT_BODY", "hello") + runtime.set_config_value("COMMENT_CREATED_AT", "2026-03-17T10:00:00Z") + runtime.set_config_value("COMMENT_SOURCE_EVENT_KEY", "issue_comment:100") + runtime.set_config_value("COMMENT_USER_TYPE", "User") + runtime.set_config_value("COMMENT_SENDER_TYPE", "User") + runtime.set_config_value("COMMENT_INSTALLATION_ID", "") + runtime.set_config_value("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") + runtime.set_config_value("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + runtime.set_config_value("COMMENT_AUTHOR_ASSOCIATION", "MEMBER") + runtime.set_config_value("CURRENT_WORKFLOW_FILE", ".github/workflows/reviewer-bot-pr-comment-trusted.yml") + runtime.set_config_value("GITHUB_REF", "refs/heads/main") + runtime.set_config_value("GITHUB_RUN_ID", "123") + runtime.set_config_value("GITHUB_RUN_ATTEMPT", "2") + + request = event_inputs.build_comment_event_request(runtime) + trust_context = event_inputs.build_pr_comment_trust_context(runtime) + + assert request.issue_number == 42 + assert request.is_pull_request is True + assert request.comment_id == 100 + assert request.comment_author == "alice" + assert request.comment_author_id == 200 + assert request.comment_source_event_key == "issue_comment:100" + assert trust_context.github_repository == "rustfoundation/safety-critical-rust-coding-guidelines" + assert trust_context.comment_author_association == "MEMBER" + assert trust_context.current_workflow_file == ".github/workflows/reviewer-bot-pr-comment-trusted.yml" + assert trust_context.github_ref == "refs/heads/main" + assert trust_context.github_run_id == 123 + assert trust_context.github_run_attempt == 2 + + +def test_event_inputs_build_assignment_and_privileged_requests_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_AUTHOR", "dana") + runtime.set_config_value("IS_PULL_REQUEST", "true") + runtime.set_config_value("ISSUE_LABELS", '["fls-audit"]') + runtime.set_config_value("REPO_OWNER", "rustfoundation") + runtime.set_config_value("REPO_NAME", "safety-critical-rust-coding-guidelines") + runtime.set_config_value("REVIEWER_BOT_TARGET_REPO_ROOT", "/tmp/repo") + runtime.set_config_value("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "42") + runtime.set_config_value("WORKFLOW_RUN_RECONCILE_HEAD_SHA", "head-1") + runtime.set_config_value("WORKFLOW_RUN_HEAD_SHA", "head-1") + + assignment_request = event_inputs.build_assignment_request(runtime, issue_number=42) + privileged_request = event_inputs.build_privileged_command_request( + runtime, + issue_number=42, + actor="alice", + command_name="accept-no-fls-changes", + ) + + assert assignment_request.issue_number == 42 + assert assignment_request.issue_author == "dana" + assert assignment_request.is_pull_request is True + assert assignment_request.issue_labels == ("fls-audit",) + assert assignment_request.repo_owner == "rustfoundation" + assert assignment_request.repo_name == "safety-critical-rust-coding-guidelines" + assert privileged_request.issue_number == 42 + assert privileged_request.actor == "alice" + assert privileged_request.command_name == "accept-no-fls-changes" + assert privileged_request.is_pull_request is True + assert privileged_request.issue_labels == ("fls-audit",) + assert privileged_request.target_repo_root == "/tmp/repo" + assert privileged_request.workflow_run_reconcile_pr_number == 42 + assert privileged_request.workflow_run_reconcile_head_sha == "head-1" + assert privileged_request.workflow_run_head_sha == "head-1" + + +def test_event_inputs_parse_labels_and_target_repo_root_from_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_LABELS", '["coding guideline", "fls-audit"]') + runtime.set_config_value("REVIEWER_BOT_TARGET_REPO_ROOT", "/tmp/repo") + + assert event_inputs.parse_issue_labels(runtime) == ["coding guideline", "fls-audit"] + assert str(event_inputs.get_target_repo_root(runtime)) == "/tmp/repo" + + def test_event_inputs_build_issue_lifecycle_request_from_runtime_config(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("ISSUE_NUMBER", "42") diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index 921a88722..5172f5082 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -128,7 +128,7 @@ def automation_runner(self) -> AutomationRunner: return runner def assignment_request(self, *, issue_number: int): - labels = tuple(event_inputs.parse_issue_labels_env()) + labels = tuple(event_inputs.parse_issue_labels(self.runtime)) return build_assignment_request( issue_number=issue_number, issue_author=self.config.get("ISSUE_AUTHOR", ""), @@ -158,7 +158,7 @@ def typed_assignment_request( ) def privileged_request(self, *, issue_number: int, actor: str = "", command_name: str = ""): - labels = tuple(event_inputs.parse_issue_labels_env()) + labels = tuple(event_inputs.parse_issue_labels(self.runtime)) return build_privileged_command_request( issue_number=issue_number, actor=actor, @@ -318,8 +318,8 @@ def handle_comment_event(self, state: dict, *, request: CommentEventRequest | No return comment_routing_module.handle_comment_event( self.runtime, state, - request or event_inputs.build_comment_event_request(), - trust_context or event_inputs.build_pr_comment_trust_context(), + request or event_inputs.build_comment_event_request(self.runtime), + trust_context or event_inputs.build_pr_comment_trust_context(self.runtime), ) def handle_manual_dispatch(self, state: dict): diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 0fc8b0c44..5aa7bd380 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -21,10 +21,10 @@ def __init__(self, monkeypatch): self.config = self.runtime.config def env_build_request(self, *, issue_number: int | None = None): - return event_inputs.build_comment_event_request(issue_number=issue_number) + return event_inputs.build_comment_event_request(self.runtime, issue_number=issue_number) def env_build_trust_context(self): - return event_inputs.build_pr_comment_trust_context() + return event_inputs.build_pr_comment_trust_context(self.runtime) wrapper_request = env_build_request wrapper_trust_context = env_build_trust_context diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index f1fd621e3..f7cfa8165 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -459,7 +459,7 @@ def get_commands_help(self) -> str: return config_module.get_commands_help() def parse_issue_labels(self) -> list[str]: - return automation_module.bot_parse_issue_labels() + return automation_module.bot_parse_issue_labels(self) def run_command(self, command, cwd, check=False): return automation_module.run_command(command, cwd=cwd, check=check) From 700d0b172ee6b7510dd7ad53148ae76e46285c55 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 07:35:51 +0900 Subject: [PATCH 120/209] refactor(reviewer-bot): use typed request builders in harnesses --- tests/fixtures/commands_harness.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index 5172f5082..b8b72ab58 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -128,15 +128,7 @@ def automation_runner(self) -> AutomationRunner: return runner def assignment_request(self, *, issue_number: int): - labels = tuple(event_inputs.parse_issue_labels(self.runtime)) - return build_assignment_request( - issue_number=issue_number, - issue_author=self.config.get("ISSUE_AUTHOR", ""), - is_pull_request=self.config.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=labels, - repo_owner=self.config.get("REPO_OWNER", ""), - repo_name=self.config.get("REPO_NAME", ""), - ) + return event_inputs.build_assignment_request(self.runtime, issue_number=issue_number) def typed_assignment_request( self, @@ -158,21 +150,11 @@ def typed_assignment_request( ) def privileged_request(self, *, issue_number: int, actor: str = "", command_name: str = ""): - labels = tuple(event_inputs.parse_issue_labels(self.runtime)) - return build_privileged_command_request( + return event_inputs.build_privileged_command_request( + self.runtime, issue_number=issue_number, actor=actor, command_name=command_name, - is_pull_request=self.config.get("IS_PULL_REQUEST", "false").lower() == "true", - issue_labels=labels, - target_repo_root=self.config.get("REVIEWER_BOT_TARGET_REPO_ROOT", ""), - workflow_run_reconcile_pr_number=( - int(self.config.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "0") or 0) - if self.config.get("WORKFLOW_RUN_RECONCILE_PR_NUMBER", "").strip() - else None - ), - workflow_run_reconcile_head_sha=self.config.get("WORKFLOW_RUN_RECONCILE_HEAD_SHA", ""), - workflow_run_head_sha=self.config.get("WORKFLOW_RUN_HEAD_SHA", ""), ) def typed_privileged_request( From f075a9850b0561a3c875792027d66c3c908baefa Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:24:58 +0900 Subject: [PATCH 121/209] refactor(reviewer-bot): remove ambient env reads from event handlers --- scripts/reviewer_bot_lib/events.py | 20 ++++----- tests/unit/reviewer_bot/test_events.py | 56 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_events.py diff --git a/scripts/reviewer_bot_lib/events.py b/scripts/reviewer_bot_lib/events.py index 80549b198..40889164c 100644 --- a/scripts/reviewer_bot_lib/events.py +++ b/scripts/reviewer_bot_lib/events.py @@ -2,19 +2,17 @@ from __future__ import annotations -import os - def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" -def _is_pr_event() -> bool: - return os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" +def _is_pr_event(bot) -> bool: + return bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true" -def _require_v18_for_pr(state: dict, context: str) -> bool: - if not _is_pr_event(): +def _require_v18_for_pr(bot, state: dict, context: str) -> bool: + if not _is_pr_event(bot): return True epoch = _runtime_epoch(state) if epoch != "freshness_v15": @@ -23,23 +21,25 @@ def _require_v18_for_pr(state: dict, context: str) -> bool: return True -def _require_legacy_for_legacy_pr(state: dict, context: str) -> bool: - if not _is_pr_event(): +def _require_legacy_for_legacy_pr(bot, state: dict, context: str) -> bool: + if not _is_pr_event(bot): return True epoch = _runtime_epoch(state) if epoch == "freshness_v15": print(f"Legacy PR freshness path safe-noop for {context}; epoch is {epoch}") return False return True + + def handle_pull_request_review_event(bot, state: dict) -> bool: - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + issue_number = int(bot.get_config_value("ISSUE_NUMBER", "0") or 0) if not issue_number: return False bot.collect_touched_item(issue_number) if _runtime_epoch(state) == "freshness_v15": print("Legacy direct pull_request_review mutation disabled after epoch flip") return False - review_action = os.environ.get("EVENT_ACTION", "").strip().lower() + review_action = bot.get_config_value("EVENT_ACTION", "").strip().lower() if review_action not in {"submitted", "dismissed"}: return False print(f"Deferring pull_request_review {review_action} for #{issue_number}") diff --git a/tests/unit/reviewer_bot/test_events.py b/tests/unit/reviewer_bot/test_events.py new file mode 100644 index 000000000..607507c32 --- /dev/null +++ b/tests/unit/reviewer_bot/test_events.py @@ -0,0 +1,56 @@ +from scripts.reviewer_bot_lib import events +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime + + +def test_is_pr_event_reads_runtime_config(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("IS_PULL_REQUEST", "true") + + assert events._is_pr_event(runtime) is True + + +def test_require_v18_for_pr_safe_noops_before_epoch_flip(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("IS_PULL_REQUEST", "true") + + assert events._require_v18_for_pr(runtime, {"freshness_runtime_epoch": "legacy_v14"}, "review-event") is False + + +def test_require_legacy_for_legacy_pr_safe_noops_after_epoch_flip(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("IS_PULL_REQUEST", "true") + + assert events._require_legacy_for_legacy_pr(runtime, {"freshness_runtime_epoch": "freshness_v15"}, "review-event") is False + + +def test_handle_pull_request_review_event_collects_touched_item_and_defers(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("EVENT_ACTION", "submitted") + + changed = events.handle_pull_request_review_event(runtime, {"freshness_runtime_epoch": "legacy_v14"}) + + assert changed is False + assert runtime.drain_touched_items() == [42] + + +def test_handle_pull_request_review_event_safe_noops_after_epoch_flip(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("EVENT_ACTION", "dismissed") + + changed = events.handle_pull_request_review_event(runtime, {"freshness_runtime_epoch": "freshness_v15"}) + + assert changed is False + assert runtime.drain_touched_items() == [42] + + +def test_handle_pull_request_review_event_ignores_unsupported_actions(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("ISSUE_NUMBER", "42") + runtime.set_config_value("EVENT_ACTION", "edited") + + changed = events.handle_pull_request_review_event(runtime, {"freshness_runtime_epoch": "legacy_v14"}) + + assert changed is False + assert runtime.drain_touched_items() == [42] From 7060f1ab2957cd8fa2cebdd8f9a012fbfb8ba2c2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:26:07 +0900 Subject: [PATCH 122/209] refactor(reviewer-bot): read reviewer board settings from runtime config --- scripts/reviewer_bot_lib/project_board.py | 4 +--- tests/unit/reviewer_bot/test_project_board.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index d25a7dd79..c97866d2d 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -3,7 +3,6 @@ from __future__ import annotations import copy -import os from dataclasses import asdict, dataclass from typing import Any @@ -121,8 +120,7 @@ class BoardPreviewResult: def reviewer_board_enabled(bot) -> bool: - del bot - return os.environ.get(REVIEWER_BOARD_ENABLED_ENV, "false").strip().lower() == "true" + return bot.get_config_value(REVIEWER_BOARD_ENABLED_ENV, "false").strip().lower() == "true" def _field_type_name(field_node: dict[str, Any]) -> str: diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index bc5d0fc4f..16d6dd2f3 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -39,6 +39,16 @@ def test_reviewer_board_preflight_validates_manifest(monkeypatch): assert preflight.project_id == "PVT_kwDOB" +def test_reviewer_board_preflight_is_disabled_without_runtime_flag(monkeypatch): + runtime = _runtime(monkeypatch) + + preflight = project_board.reviewer_board_preflight(runtime) + + assert preflight.enabled is False + assert preflight.valid is True + assert preflight.project_id is None + + def test_preview_board_projection_valid_manifest_yields_preview_output(monkeypatch): state = make_state() make_tracked_review_state(state, 42, reviewer="alice", assigned_at="2026-03-20T12:34:56Z", active_cycle_started_at="2026-03-20T12:34:56Z") From c01d279fdeeba34b2426d748f031ed1d179b0332 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:28:25 +0900 Subject: [PATCH 123/209] refactor(reviewer-bot): add typed runtime config accessors --- scripts/reviewer_bot_lib/runtime.py | 38 +++++++++++++++++++ .../reviewer_bot/test_adapter_contract.py | 21 ++++++++++ 2 files changed, 59 insertions(+) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index b9908d307..1fb02a85a 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -19,10 +19,18 @@ EVENT_INTENT_NON_MUTATING_DEFER, EVENT_INTENT_NON_MUTATING_READONLY, FLS_AUDIT_LABEL, + LOCK_API_RETRY_LIMIT, + LOCK_LEASE_TTL_SECONDS, + LOCK_MAX_WAIT_SECONDS, + LOCK_REF_BOOTSTRAP_BRANCH, + LOCK_REF_NAME, + LOCK_RENEWAL_WINDOW_SECONDS, + LOCK_RETRY_BASE_SECONDS, REVIEW_DEADLINE_DAYS, REVIEW_FRESHNESS_RUNBOOK_PATH, REVIEW_LABELS, REVIEWER_REQUEST_422_TEMPLATE, + STATE_ISSUE_NUMBER, STATUS_PROJECTION_EPOCH, TRANSITION_PERIOD_DAYS, ) @@ -136,6 +144,36 @@ def get_config_value(self, name: str, default: str = "") -> str: def set_config_value(self, name: str, value: Any) -> None: self.config.set(name, value) + def state_issue_number(self) -> int: + return int(self.get_config_value("STATE_ISSUE_NUMBER", str(STATE_ISSUE_NUMBER or 0)) or 0) + + def lock_api_retry_limit(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", str(LOCK_API_RETRY_LIMIT)) or 0) + + def lock_retry_base_seconds(self) -> float: + return float(self.get_config_value("REVIEWER_BOT_LOCK_RETRY_SECONDS", str(LOCK_RETRY_BASE_SECONDS)) or 0.0) + + def lock_max_wait_seconds(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", str(LOCK_MAX_WAIT_SECONDS)) or 0) + + def lock_lease_ttl_seconds(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_TTL_SECONDS", str(LOCK_LEASE_TTL_SECONDS)) or 0) + + def lock_renewal_window_seconds(self) -> int: + return int( + self.get_config_value( + "REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", + str(LOCK_RENEWAL_WINDOW_SECONDS), + ) + or 0 + ) + + def lock_ref_name(self) -> str: + return self.get_config_value("REVIEWER_BOT_LOCK_REF_NAME", LOCK_REF_NAME) + + def lock_ref_bootstrap_branch(self) -> str: + return self.get_config_value("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", LOCK_REF_BOOTSTRAP_BRANCH) + def write_output(self, name: str, value: str) -> None: self.outputs.write(name, value) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index e2c7e8289..51d4bc4d6 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -207,3 +207,24 @@ def test_event_inputs_build_label_and_sync_requests_from_runtime_config(monkeypa assert sync_request.issue_number == 42 assert sync_request.head_sha == "head-2" assert sync_request.event_created_at == "2026-03-17T10:05:00Z" + + +def test_runtime_typed_config_accessors_read_runtime_config(monkeypatch): + runtime = reviewer_bot._runtime_bot() + monkeypatch.setenv("STATE_ISSUE_NUMBER", "77") + monkeypatch.setenv("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", "9") + monkeypatch.setenv("REVIEWER_BOT_LOCK_RETRY_SECONDS", "3.5") + monkeypatch.setenv("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", "180") + monkeypatch.setenv("REVIEWER_BOT_LOCK_TTL_SECONDS", "600") + monkeypatch.setenv("REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", "90") + monkeypatch.setenv("REVIEWER_BOT_LOCK_REF_NAME", "heads/test-lock") + monkeypatch.setenv("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", "develop") + + assert runtime.state_issue_number() == 77 + assert runtime.lock_api_retry_limit() == 9 + assert runtime.lock_retry_base_seconds() == 3.5 + assert runtime.lock_max_wait_seconds() == 180 + assert runtime.lock_lease_ttl_seconds() == 600 + assert runtime.lock_renewal_window_seconds() == 90 + assert runtime.lock_ref_name() == "heads/test-lock" + assert runtime.lock_ref_bootstrap_branch() == "develop" From b87e102e99104a562c828a8234150cabd0a68bae Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:30:52 +0900 Subject: [PATCH 124/209] refactor(reviewer-bot): add injectable runtime infra services --- scripts/reviewer_bot_lib/runtime.py | 47 +++++++++++++++++++ .../reviewer_bot/test_adapter_contract.py | 33 +++++++++++++ 2 files changed, 80 insertions(+) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 1fb02a85a..b1dc74901 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -4,6 +4,7 @@ import json import os +import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -83,6 +84,42 @@ def drain(self) -> list[int]: return touched +class SystemClock: + def now(self) -> datetime: + return datetime.now(timezone.utc) + + +class SystemSleeper: + def __init__(self, time_module: Any): + self._time = time_module + + def sleep(self, seconds: float) -> None: + self._time.sleep(seconds) + + +class RandomJitterSource: + def __init__(self, random_module: Any): + self._random = random_module + + def uniform(self, lower: float, upper: float) -> float: + return self._random.uniform(lower, upper) + + +class Uuid4Source: + def uuid4_hex(self) -> str: + return uuid.uuid4().hex + + +class StdErrLogger: + def __init__(self, sys_module: Any): + self._sys = sys_module + + def event(self, level: str, message: str, **fields: Any) -> None: + rendered_fields = " ".join(f"{key}={value}" for key, value in sorted(fields.items())) + suffix = f" {rendered_fields}" if rendered_fields else "" + self._sys.stderr.write(f"[{level}] {message}{suffix}\n") + + class ReviewerBotRuntime: """Runtime object built from explicit services and named adapters.""" @@ -115,6 +152,11 @@ def __init__( config: Any | None = None, outputs: Any | None = None, deferred_payloads: Any | None = None, + clock: Any | None = None, + sleeper: Any | None = None, + jitter: Any | None = None, + uuid_source: Any | None = None, + logger: Any | None = None, state_store: Any, github: Any, locks: Any, @@ -130,6 +172,11 @@ def __init__( self.config = config or _EnvConfig() self.outputs = outputs or _FileOutputSink(self.config) self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) + self.clock = clock or SystemClock() + self.sleeper = sleeper or SystemSleeper(time) + self.jitter = jitter or RandomJitterSource(random) + self.uuid_source = uuid_source or Uuid4Source() + self.logger = logger or StdErrLogger(sys) self.state_store = state_store self.github = github self.locks = locks diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 51d4bc4d6..34a6d0e86 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from typing import get_type_hints import pytest @@ -7,6 +8,7 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import event_inputs, lease_lock, review_state from scripts.reviewer_bot_lib.context import ReviewerBotContext +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state @@ -228,3 +230,34 @@ def test_runtime_typed_config_accessors_read_runtime_config(monkeypatch): assert runtime.lock_renewal_window_seconds() == 90 assert runtime.lock_ref_name() == "heads/test-lock" assert runtime.lock_ref_bootstrap_branch() == "develop" + + +def test_runtime_accepts_injected_infra_services(): + clock = object() + sleeper = object() + jitter = object() + uuid_source = object() + logger = object() + + runtime = ReviewerBotRuntime( + requests=SimpleNamespace(), + sys=SimpleNamespace(stderr=SimpleNamespace(write=lambda _text: None)), + random=SimpleNamespace(uniform=lambda lower, upper: lower), + time=SimpleNamespace(sleep=lambda _seconds: None), + clock=clock, + sleeper=sleeper, + jitter=jitter, + uuid_source=uuid_source, + logger=logger, + state_store=SimpleNamespace(load_state=lambda **kwargs: {}, save_state=lambda state: True), + github=SimpleNamespace(github_api=lambda *args, **kwargs: {}, github_api_request=lambda *args, **kwargs: {}), + locks=SimpleNamespace(), + handlers=SimpleNamespace(), + adapters=SimpleNamespace(), + ) + + assert runtime.clock is clock + assert runtime.sleeper is sleeper + assert runtime.jitter is jitter + assert runtime.uuid_source is uuid_source + assert runtime.logger is logger From 1cd08fed87a268cc6c6a9508de14e001dcd365c0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:33:50 +0900 Subject: [PATCH 125/209] test(reviewer-bot): add deterministic infra fakes --- tests/fixtures/fake_clock.py | 18 ++++++ tests/fixtures/fake_jitter.py | 18 ++++++ tests/fixtures/fake_sleeper.py | 9 +++ tests/fixtures/fake_uuid.py | 19 +++++++ tests/fixtures/recording_logger.py | 15 +++++ .../reviewer_bot/test_fixture_contracts.py | 56 +++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 tests/fixtures/fake_clock.py create mode 100644 tests/fixtures/fake_jitter.py create mode 100644 tests/fixtures/fake_sleeper.py create mode 100644 tests/fixtures/fake_uuid.py create mode 100644 tests/fixtures/recording_logger.py diff --git a/tests/fixtures/fake_clock.py b/tests/fixtures/fake_clock.py new file mode 100644 index 000000000..5bed4f631 --- /dev/null +++ b/tests/fixtures/fake_clock.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + + +class FakeClock: + def __init__(self, now: datetime | None = None): + self._now = now or datetime(2026, 1, 1, tzinfo=timezone.utc) + + def now(self) -> datetime: + return self._now + + def set(self, now: datetime) -> None: + self._now = now + + def advance(self, *, seconds: float = 0, delta: timedelta | None = None) -> datetime: + self._now = self._now + (delta or timedelta(seconds=seconds)) + return self._now diff --git a/tests/fixtures/fake_jitter.py b/tests/fixtures/fake_jitter.py new file mode 100644 index 000000000..df3a8abeb --- /dev/null +++ b/tests/fixtures/fake_jitter.py @@ -0,0 +1,18 @@ +from __future__ import annotations + + +class DeterministicJitter: + def __init__(self, values: float | list[float] = 0.0): + self._values = [float(values)] if isinstance(values, (float, int)) else [float(value) for value in values] + self.calls: list[tuple[float, float]] = [] + self._index = 0 + + def uniform(self, lower: float, upper: float) -> float: + self.calls.append((lower, upper)) + if not self._values: + return lower + if self._index >= len(self._values): + return float(self._values[-1]) + value = float(self._values[self._index]) + self._index += 1 + return value diff --git a/tests/fixtures/fake_sleeper.py b/tests/fixtures/fake_sleeper.py new file mode 100644 index 000000000..7be0e4288 --- /dev/null +++ b/tests/fixtures/fake_sleeper.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class RecordingSleeper: + def __init__(self): + self.calls: list[float] = [] + + def sleep(self, seconds: float) -> None: + self.calls.append(seconds) diff --git a/tests/fixtures/fake_uuid.py b/tests/fixtures/fake_uuid.py new file mode 100644 index 000000000..dc7ddf274 --- /dev/null +++ b/tests/fixtures/fake_uuid.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class FixedUuidSource: + def __init__(self, values: str | list[str] = "fixed-uuid"): + self._values = [values] if isinstance(values, str) else list(values) + self.issued: list[str] = [] + self._index = 0 + + def uuid4_hex(self) -> str: + if not self._values: + value = "fixed-uuid" + elif self._index >= len(self._values): + value = self._values[-1] + else: + value = self._values[self._index] + self._index += 1 + self.issued.append(value) + return value diff --git a/tests/fixtures/recording_logger.py b/tests/fixtures/recording_logger.py new file mode 100644 index 000000000..31aeebec1 --- /dev/null +++ b/tests/fixtures/recording_logger.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +class RecordingLogger: + def __init__(self): + self.records: list[dict[str, object]] = [] + + def event(self, level: str, message: str, **fields) -> None: + self.records.append( + { + "level": level, + "message": message, + "fields": dict(fields), + } + ) diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index f4ef5b295..53d149ba7 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -1,3 +1,10 @@ +from datetime import datetime, timezone + +from tests.fixtures.fake_clock import FakeClock +from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_sleeper import RecordingSleeper +from tests.fixtures.fake_uuid import FixedUuidSource +from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result @@ -15,3 +22,52 @@ def test_shared_github_fixture_smoke_routes_simple_request(): result = routes.github_api_request("GET", "pulls/42") assert result.payload == {"head": {"sha": "head-1"}} + + +def test_fake_clock_can_set_and_advance_time(): + clock = FakeClock(datetime(2026, 3, 17, 10, 0, tzinfo=timezone.utc)) + + assert clock.now() == datetime(2026, 3, 17, 10, 0, tzinfo=timezone.utc) + clock.advance(seconds=30) + assert clock.now() == datetime(2026, 3, 17, 10, 0, 30, tzinfo=timezone.utc) + + +def test_recording_sleeper_captures_sleep_calls(): + sleeper = RecordingSleeper() + + sleeper.sleep(1.5) + sleeper.sleep(2.0) + + assert sleeper.calls == [1.5, 2.0] + + +def test_deterministic_jitter_replays_configured_values(): + jitter = DeterministicJitter([0.5, 0.75]) + + assert jitter.uniform(0.1, 1.0) == 0.5 + assert jitter.uniform(0.1, 1.0) == 0.75 + assert jitter.uniform(0.1, 1.0) == 0.75 + assert jitter.calls == [(0.1, 1.0), (0.1, 1.0), (0.1, 1.0)] + + +def test_fixed_uuid_source_replays_values_and_records_issued_ids(): + source = FixedUuidSource(["uuid-1", "uuid-2"]) + + assert source.uuid4_hex() == "uuid-1" + assert source.uuid4_hex() == "uuid-2" + assert source.uuid4_hex() == "uuid-2" + assert source.issued == ["uuid-1", "uuid-2", "uuid-2"] + + +def test_recording_logger_captures_structured_events(): + logger = RecordingLogger() + + logger.event("warning", "retrying request", issue_number=42, retry_attempt=2) + + assert logger.records == [ + { + "level": "warning", + "message": "retrying request", + "fields": {"issue_number": 42, "retry_attempt": 2}, + } + ] From 0a82b9460d416ffe857707ab4820a0c83f464ae6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:37:37 +0900 Subject: [PATCH 126/209] refactor(reviewer-bot): inject time and logging into state store --- scripts/reviewer_bot_lib/state_store.py | 217 +++++++++++++------- tests/unit/reviewer_bot/test_state_store.py | 49 ++++- 2 files changed, 191 insertions(+), 75 deletions(-) diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index a1f2f7fb3..6be9f2324 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -1,10 +1,7 @@ """State issue parsing, loading, and saving helpers.""" import json -import random import re -import sys -import time from datetime import datetime, timezone from typing import Any @@ -30,16 +27,71 @@ from .context import StateStoreContext +def _log(bot: StateStoreContext, level: str, message: str, **fields: Any) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + print(message, file=__import__("sys").stderr) + + +def _log_fallback(level: str, message: str) -> None: + print(message, file=__import__("sys").stderr) + + +def _sleep(bot: StateStoreContext, seconds: float) -> None: + sleeper = getattr(bot, "sleeper", None) + if sleeper is not None and hasattr(sleeper, "sleep"): + sleeper.sleep(seconds) + return + __import__("time").sleep(seconds) + + +def _jitter(bot: StateStoreContext, lower: float, upper: float) -> float: + jitter = getattr(bot, "jitter", None) + if jitter is not None and hasattr(jitter, "uniform"): + return jitter.uniform(lower, upper) + return __import__("random").uniform(lower, upper) + + +def _now_iso(bot: StateStoreContext) -> str: + clock = getattr(bot, "clock", None) + if clock is not None and hasattr(clock, "now"): + return clock.now().isoformat() + return datetime.now(timezone.utc).isoformat() + + +def _state_issue_number(bot: StateStoreContext) -> int: + accessor = getattr(bot, "state_issue_number", None) + if callable(accessor): + return accessor() + return getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + + +def _lock_api_retry_limit(bot: StateStoreContext) -> int: + accessor = getattr(bot, "lock_api_retry_limit", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) + + +def _lock_retry_base_seconds(bot: StateStoreContext) -> float: + accessor = getattr(bot, "lock_retry_base_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + + def get_state_issue(bot: StateStoreContext) -> dict | None: """Fetch the state issue from GitHub with retry for transient failures.""" - state_issue_number = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + state_issue_number = _state_issue_number(bot) state_read_retry_limit = getattr(bot, "STATE_READ_RETRY_LIMIT", STATE_READ_RETRY_LIMIT) state_read_retry_base_seconds = getattr( bot, "STATE_READ_RETRY_BASE_SECONDS", STATE_READ_RETRY_BASE_SECONDS ) if not state_issue_number: - print("ERROR: STATE_ISSUE_NUMBER not set", file=sys.stderr) + _log(bot, "error", "STATE_ISSUE_NUMBER not set") return None for attempt in range(1, state_read_retry_limit + 1): @@ -51,43 +103,52 @@ def get_state_issue(bot: StateStoreContext) -> dict | None: if response.status_code == 200: if not isinstance(response.payload, dict): - print("ERROR: State issue response payload was not an object", file=sys.stderr) + _log(bot, "error", "State issue response payload was not an object") return None return response.payload if response.status_code in {401, 403, 404}: - print( - "ERROR: Failed to fetch state issue " - f"#{state_issue_number} (status {response.status_code}): {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Failed to fetch state issue #{state_issue_number} (status {response.status_code}): {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return None if response.status_code == 429 or response.status_code >= 500: if attempt < state_read_retry_limit: - delay = state_read_retry_base_seconds + random.uniform(0, state_read_retry_base_seconds) - print( - "WARNING: Retryable state issue read failure " - f"(status {response.status_code}); retrying ({attempt}/{state_read_retry_limit})", - file=sys.stderr, + delay = state_read_retry_base_seconds + _jitter(bot, 0, state_read_retry_base_seconds) + _log( + bot, + "warning", + f"Retryable state issue read failure (status {response.status_code}); retrying ({attempt}/{state_read_retry_limit})", + state_issue_number=state_issue_number, + status_code=response.status_code, + retry_attempt=attempt, ) - time.sleep(delay) + _sleep(bot, delay) continue - print( - "ERROR: Exhausted retries while fetching state issue " - f"#{state_issue_number}; last status {response.status_code}: {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Exhausted retries while fetching state issue #{state_issue_number}; last status {response.status_code}: {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return None - print( - "ERROR: Unexpected status while fetching state issue " - f"#{state_issue_number}: {response.status_code} {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Unexpected status while fetching state issue #{state_issue_number}: {response.status_code} {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return None - print(f"ERROR: Failed to fetch state issue #{state_issue_number} after retries", file=sys.stderr) + _log(bot, "error", f"Failed to fetch state issue #{state_issue_number} after retries", state_issue_number=state_issue_number) return None @@ -191,7 +252,7 @@ def parse_state_yaml_from_issue_body(body: str) -> dict: try: state = yaml.safe_load(yaml_content) or {} except yaml.YAMLError as exc: - print(f"WARNING: Failed to parse state YAML: {exc}", file=sys.stderr) + _log_fallback("warning", f"Failed to parse state YAML: {exc}") state = {} if not isinstance(state, dict): @@ -211,7 +272,7 @@ def parse_lock_metadata_from_issue_body(body: str) -> dict: try: parsed = json.loads(lock_json) except json.JSONDecodeError as exc: - print(f"WARNING: Failed to parse lock metadata JSON: {exc}", file=sys.stderr) + _log_fallback("warning", f"Failed to parse lock metadata JSON: {exc}") return normalize_lock_metadata(None) if not isinstance(parsed, dict): @@ -271,9 +332,9 @@ def parse_state_from_issue(issue: dict) -> dict: def get_state_issue_snapshot(bot: StateStoreContext) -> StateIssueSnapshot | None: - state_issue_number = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + state_issue_number = _state_issue_number(bot) if not state_issue_number: - print("ERROR: STATE_ISSUE_NUMBER not set", file=sys.stderr) + _log(bot, "error", "STATE_ISSUE_NUMBER not set") return None response = bot.github_api_request( @@ -283,15 +344,17 @@ def get_state_issue_snapshot(bot: StateStoreContext) -> StateIssueSnapshot | Non suppress_error_log=True, ) if response.status_code != 200: - print( - "ERROR: Failed to fetch state issue " - f"#{state_issue_number} (status {response.status_code}): {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Failed to fetch state issue #{state_issue_number} (status {response.status_code}): {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return None if not isinstance(response.payload, dict): - print("ERROR: State issue response payload was not an object", file=sys.stderr) + _log(bot, "error", "State issue response payload was not an object") return None body = response.payload.get("body") @@ -300,14 +363,14 @@ def get_state_issue_snapshot(bot: StateStoreContext) -> StateIssueSnapshot | Non html_url = response.payload.get("html_url") if not isinstance(html_url, str) or not html_url: - repo = f"{__import__('os').environ.get('REPO_OWNER', '')}/{__import__('os').environ.get('REPO_NAME', '')}".strip("/") + repo = f"{bot.get_config_value('REPO_OWNER', '')}/{bot.get_config_value('REPO_NAME', '')}".strip("/") html_url = f"https://github.com/{repo}/issues/{state_issue_number}" if repo else "" return StateIssueSnapshot(body=body, etag=response.headers.get("etag"), html_url=html_url) def conditional_patch_state_issue(bot: StateStoreContext, body: str, etag: str | None = None): - state_issue_number = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + state_issue_number = _state_issue_number(bot) extra_headers = {"If-Match": etag} if isinstance(etag, str) and etag else None return bot.github_api_request( "PATCH", @@ -343,7 +406,7 @@ def load_state(bot: StateStoreContext, *, fail_on_unavailable: bool = False) -> "State issue is unavailable for a mutating event; refusing to continue " "with fallback defaults." ) - print("WARNING: Could not fetch state issue, using defaults", file=sys.stderr) + _log(bot, "warning", "Could not fetch state issue, using defaults") return default_state state = parse_state_from_issue(issue) @@ -371,19 +434,19 @@ def load_state(bot: StateStoreContext, *, fail_on_unavailable: bool = False) -> def save_state(bot: StateStoreContext, state: dict) -> bool: assert_lock_held(bot, "save_state") - state_issue_number = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) - lock_api_retry_limit = getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) - lock_retry_base_seconds = getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + state_issue_number = _state_issue_number(bot) + lock_api_retry_limit = _lock_api_retry_limit(bot) + lock_retry_base_seconds = _lock_retry_base_seconds(bot) if not state_issue_number: - print("ERROR: STATE_ISSUE_NUMBER not set", file=sys.stderr) + _log(bot, "error", "STATE_ISSUE_NUMBER not set") return False - state["last_updated"] = datetime.now(timezone.utc).isoformat() + state["last_updated"] = _now_iso(bot) for attempt in range(1, lock_api_retry_limit + 1): if not bot.ensure_state_issue_lease_lock_fresh(): - print("ERROR: Failed to refresh reviewer-bot lease lock before save", file=sys.stderr) + _log(bot, "error", "Failed to refresh reviewer-bot lease lock before save") return False snapshot = bot.get_state_issue_snapshot() @@ -395,58 +458,68 @@ def save_state(bot: StateStoreContext, state: dict) -> bool: response = bot.conditional_patch_state_issue(body, snapshot.etag) if response.status_code == 200: - print(f"State saved to issue #{state_issue_number}") + _log(bot, "info", f"State saved to issue #{state_issue_number}", state_issue_number=state_issue_number) return True if response.status_code in {409, 412}: - print( - "WARNING: State save hit conflict " - f"(status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})", - file=sys.stderr, + _log( + bot, + "warning", + f"State save hit conflict (status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})", + state_issue_number=state_issue_number, + status_code=response.status_code, + retry_attempt=attempt, ) - delay = lock_retry_base_seconds + random.uniform(0, lock_retry_base_seconds) - time.sleep(delay) + delay = lock_retry_base_seconds + _jitter(bot, 0, lock_retry_base_seconds) + _sleep(bot, delay) continue if response.status_code == 404: - print( - f"ERROR: State issue #{state_issue_number} not found during save_state", - file=sys.stderr, - ) + _log(bot, "error", f"State issue #{state_issue_number} not found during save_state", state_issue_number=state_issue_number) return False if response.status_code in {401, 403}: - print( - "ERROR: Permission failure while saving state issue " - f"#{state_issue_number} (status {response.status_code}): {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Permission failure while saving state issue #{state_issue_number} (status {response.status_code}): {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return False if response.status_code == 429 or response.status_code >= 500: if attempt < lock_api_retry_limit: - delay = lock_retry_base_seconds + random.uniform(0, lock_retry_base_seconds) - print( - "WARNING: Retryable state issue write failure " - f"(status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})", - file=sys.stderr, + delay = lock_retry_base_seconds + _jitter(bot, 0, lock_retry_base_seconds) + _log( + bot, + "warning", + f"Retryable state issue write failure (status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})", + state_issue_number=state_issue_number, + status_code=response.status_code, + retry_attempt=attempt, ) - time.sleep(delay) + _sleep(bot, delay) continue - print( - "ERROR: Exhausted retries while saving state issue " - f"#{state_issue_number}; last status {response.status_code}: {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Exhausted retries while saving state issue #{state_issue_number}; last status {response.status_code}: {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return False - print( - f"ERROR: Unexpected status {response.status_code} while saving state issue: {response.text}", - file=sys.stderr, + _log( + bot, + "error", + f"Unexpected status {response.status_code} while saving state issue: {response.text}", + state_issue_number=state_issue_number, + status_code=response.status_code, ) return False - print(f"ERROR: Failed to save state to issue #{state_issue_number} after retries", file=sys.stderr) + _log(bot, "error", f"Failed to save state to issue #{state_issue_number} after retries", state_issue_number=state_issue_number) return False diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py index dc8658f0b..460241009 100644 --- a/tests/unit/reviewer_bot/test_state_store.py +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -7,6 +7,10 @@ GitHubApiResult, StateIssueSnapshot, ) +from tests.fixtures.fake_clock import FakeClock +from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_sleeper import RecordingSleeper +from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot import make_state @@ -35,7 +39,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, get_config_value=lambda name, default="": default) snapshot = state_store.get_state_issue_snapshot(bot) @@ -81,7 +85,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): assert observed["extra_headers"] is None -def test_save_state_retries_precondition_failed_conflict(monkeypatch): +def test_save_state_retries_precondition_failed_conflict_uses_injected_time_services(): state = make_state() snapshot = StateIssueSnapshot( body="body", @@ -95,15 +99,54 @@ def test_save_state_retries_precondition_failed_conflict(monkeypatch): ] ) + clock = FakeClock() + sleeper = RecordingSleeper() + jitter = DeterministicJitter(0.25) + logger = RecordingLogger() + bot = SimpleNamespace( STATE_ISSUE_NUMBER=1, ACTIVE_LEASE_CONTEXT=object(), + clock=clock, + sleeper=sleeper, + jitter=jitter, + logger=logger, ensure_state_issue_lease_lock_fresh=lambda: True, get_state_issue_snapshot=lambda: snapshot, parse_lock_metadata_from_issue_body=lambda body: {}, render_state_issue_body=lambda state_obj, lock_meta, base_body: "updated", conditional_patch_state_issue=lambda body, etag=None: next(responses), ) - monkeypatch.setattr(state_store.time, "sleep", lambda *_args, **_kwargs: None) assert state_store.save_state(bot, state) is True + assert state["last_updated"] == clock.now().isoformat() + assert sleeper.calls == [2.25] + assert jitter.calls == [(0, 2.0)] + assert logger.records[0]["level"] == "warning" + assert logger.records[-1]["level"] == "info" + + +def test_get_state_issue_snapshot_builds_html_url_from_runtime_config_when_missing(): + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + return GitHubApiResult( + status_code=200, + payload={"body": "state: ok"}, + headers={"etag": '"abc"'}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=1, + transport_error=None, + ) + + config = {"REPO_OWNER": "rustfoundation", "REPO_NAME": "safety-critical-rust-coding-guidelines"} + bot = SimpleNamespace( + STATE_ISSUE_NUMBER=1, + github_api_request=fake_request, + get_config_value=lambda name, default="": config.get(name, default), + ) + + snapshot = state_store.get_state_issue_snapshot(bot) + + assert snapshot is not None + assert snapshot.html_url == "https://github.com/rustfoundation/safety-critical-rust-coding-guidelines/issues/1" From 4793774c62802125c4872c0fdd00f7f688c6931f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:47:01 +0900 Subject: [PATCH 127/209] refactor(reviewer-bot): inject time and identity services into lease lock --- scripts/reviewer_bot.py | 8 +- scripts/reviewer_bot_lib/lease_lock.py | 384 ++++++++++++++------- tests/unit/reviewer_bot/test_lease_lock.py | 28 +- 3 files changed, 278 insertions(+), 142 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index d987f0dd9..f2291dc00 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """Bootstrap entrypoint for reviewer-bot.""" +import random import sys +import time -from scripts.reviewer_bot_lib import github_api, lease_lock +from scripts.reviewer_bot_lib import github_api from scripts.reviewer_bot_lib.app import build_event_context as build_app_event_context from scripts.reviewer_bot_lib.app import execute_run as execute_app_run from scripts.reviewer_bot_lib.app import main as run_app_main @@ -40,8 +42,8 @@ def _build_runtime() -> ReviewerBotRuntime: return build_bootstrap_runtime( requests=github_api.requests, sys=sys, - random=lease_lock.random, - time=lease_lock.time, + random=random, + time=time, active_lease_context=ACTIVE_LEASE_CONTEXT, ) diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index 4313dbc2a..e2f9840a7 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -1,10 +1,6 @@ """Reviewer-bot lease lock helpers.""" import json -import os -import random -import time -import uuid from datetime import datetime, timezone from typing import Any @@ -22,6 +18,88 @@ from .context import LeaseLockContext +def _log(bot: LeaseLockContext, level: str, message: str, **fields: Any) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout + print(message, file=stream) + + +def _sleep(bot: LeaseLockContext, seconds: float) -> None: + sleeper = getattr(bot, "sleeper", None) + if sleeper is not None and hasattr(sleeper, "sleep"): + sleeper.sleep(seconds) + return + __import__("time").sleep(seconds) + + +def _jitter(bot: LeaseLockContext, lower: float, upper: float) -> float: + jitter = getattr(bot, "jitter", None) + if jitter is not None and hasattr(jitter, "uniform"): + return jitter.uniform(lower, upper) + return __import__("random").uniform(lower, upper) + + +def _now(bot: LeaseLockContext) -> datetime: + clock = getattr(bot, "clock", None) + if clock is not None and hasattr(clock, "now"): + return clock.now() + datetime_module = getattr(bot, "datetime", datetime) + timezone_module = getattr(bot, "timezone", timezone) + return datetime_module.now(timezone_module.utc) + + +def _monotonic(bot: LeaseLockContext) -> float: + time_module = getattr(bot, "time", None) + if time_module is not None and hasattr(time_module, "monotonic"): + return time_module.monotonic() + return __import__("time").monotonic() + + +def _uuid4_hex(bot: LeaseLockContext) -> str: + source = getattr(bot, "uuid_source", None) + if source is not None and hasattr(source, "uuid4_hex"): + return source.uuid4_hex() + return __import__("uuid").uuid4().hex + + +def _lock_lease_ttl_seconds(bot: LeaseLockContext) -> int: + accessor = getattr(bot, "lock_lease_ttl_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_LEASE_TTL_SECONDS", LOCK_LEASE_TTL_SECONDS) + + +def _lock_api_retry_limit(bot: LeaseLockContext) -> int: + accessor = getattr(bot, "lock_api_retry_limit", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) + + +def _lock_retry_base_seconds(bot: LeaseLockContext) -> float: + accessor = getattr(bot, "lock_retry_base_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + + +def _lock_max_wait_seconds(bot: LeaseLockContext) -> int: + accessor = getattr(bot, "lock_max_wait_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_MAX_WAIT_SECONDS", LOCK_MAX_WAIT_SECONDS) + + +def _lock_renewal_window_seconds(bot: LeaseLockContext) -> int: + accessor = getattr(bot, "lock_renewal_window_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "LOCK_RENEWAL_WINDOW_SECONDS", LOCK_RENEWAL_WINDOW_SECONDS) + + def lock_is_currently_valid(bot: LeaseLockContext, lock_meta: dict, now: datetime | None = None) -> bool: if not isinstance(lock_meta, dict): return False @@ -33,32 +111,32 @@ def lock_is_currently_valid(bot: LeaseLockContext, lock_meta: dict, now: datetim expires_at = bot.parse_iso8601_timestamp(lock_meta.get("lock_expires_at")) if expires_at is None: return False - now = now or datetime.now(timezone.utc) + now = now or _now(bot) return expires_at > now -def get_lock_owner_context() -> tuple[str, str, str]: +def get_lock_owner_context(bot: LeaseLockContext) -> tuple[str, str, str]: run_id = ( - os.environ.get("WORKFLOW_RUN_ID", "").strip() - or os.environ.get("GITHUB_RUN_ID", "").strip() + bot.get_config_value("WORKFLOW_RUN_ID", "").strip() + or bot.get_config_value("GITHUB_RUN_ID", "").strip() or "local-run" ) workflow = ( - os.environ.get("WORKFLOW_NAME", "").strip() - or os.environ.get("GITHUB_WORKFLOW", "").strip() + bot.get_config_value("WORKFLOW_NAME", "").strip() + or bot.get_config_value("GITHUB_WORKFLOW", "").strip() or "reviewer-bot" ) job = ( - os.environ.get("WORKFLOW_JOB_NAME", "").strip() - or os.environ.get("GITHUB_JOB", "").strip() + bot.get_config_value("WORKFLOW_JOB_NAME", "").strip() + or bot.get_config_value("GITHUB_JOB", "").strip() or "reviewer-bot" ) return run_id, workflow, job def build_lock_metadata(bot: LeaseLockContext, lock_token: str, lock_owner_run_id: str, lock_owner_workflow: str, lock_owner_job: str) -> dict: - acquired_at = datetime.now(timezone.utc) - expires_at = acquired_at.timestamp() + getattr(bot, "LOCK_LEASE_TTL_SECONDS", LOCK_LEASE_TTL_SECONDS) + acquired_at = _now(bot) + expires_at = acquired_at.timestamp() + _lock_lease_ttl_seconds(bot) return bot.normalize_lock_metadata( { "schema_version": 1, @@ -87,6 +165,8 @@ def normalize_lock_ref_name(ref_name: str) -> str: def get_lock_ref_name(bot: LeaseLockContext) -> str: + if callable(getattr(bot, "lock_ref_name", None)): + return normalize_lock_ref_name(bot.lock_ref_name()) return normalize_lock_ref_name(getattr(bot, "LOCK_REF_NAME", LOCK_REF_NAME)) @@ -163,9 +243,13 @@ def _activate_lease_context( lock_ref=bot.get_lock_ref_display(), lock_expires_at=lock_expires_at, ) - print( - "Acquired reviewer-bot lease lock " - f"(run_id={lock_owner_run_id}, token_prefix={lock_token[:8]}, lock_ref={bot.get_lock_ref_display()})" + _log( + bot, + "info", + "Acquired reviewer-bot lease lock", + run_id=lock_owner_run_id, + token_prefix=lock_token[:8], + lock_ref=bot.get_lock_ref_display(), ) return bot.ACTIVE_LEASE_CONTEXT @@ -213,7 +297,10 @@ def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: f"{get_lock_ref_display(bot)} (status {response.status_code}): {response.text}" ) - default_branch = getattr(bot, "LOCK_REF_BOOTSTRAP_BRANCH", LOCK_REF_BOOTSTRAP_BRANCH) + if callable(getattr(bot, "lock_ref_bootstrap_branch", None)): + default_branch = bot.lock_ref_bootstrap_branch() + else: + default_branch = getattr(bot, "LOCK_REF_BOOTSTRAP_BRANCH", LOCK_REF_BOOTSTRAP_BRANCH) branch_response = bot.github_api_request( "GET", f"git/ref/heads/{default_branch}", @@ -311,32 +398,37 @@ def ensure_state_issue_lease_lock_fresh(bot: LeaseLockContext) -> bool: expires_at = bot.parse_iso8601_timestamp(context.lock_expires_at) if expires_at is None: return bot.renew_state_issue_lease_lock(context) - remaining_seconds = (expires_at - datetime.now(timezone.utc)).total_seconds() - renewal_window = getattr(bot, "LOCK_RENEWAL_WINDOW_SECONDS", LOCK_RENEWAL_WINDOW_SECONDS) + remaining_seconds = (expires_at - _now(bot)).total_seconds() + renewal_window = _lock_renewal_window_seconds(bot) if remaining_seconds > renewal_window: return True - print( - "Reviewer-bot lease lock nearing expiry; attempting renewal " - f"(remaining={int(remaining_seconds)}s, token_prefix={context.lock_token[:8]})" + _log( + bot, + "info", + "Reviewer-bot lease lock nearing expiry; attempting renewal", + remaining_seconds=int(remaining_seconds), + token_prefix=context.lock_token[:8], ) return bot.renew_state_issue_lease_lock(context) def renew_state_issue_lease_lock(bot: LeaseLockContext, context: LeaseContext) -> bool: - retry_limit = getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) - retry_base = getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + retry_limit = _lock_api_retry_limit(bot) + retry_base = _lock_retry_base_seconds(bot) for attempt in range(1, retry_limit + 1): try: ref_head_sha, tree_sha, current_lock = bot.get_lock_ref_snapshot() except RuntimeError as exc: - print(f"ERROR: Failed to read lock snapshot during renewal: {exc}", file=bot.sys.stderr) + _log(bot, "error", f"Failed to read lock snapshot during renewal: {exc}") return False current_token = current_lock.get("lock_token") if current_token != context.lock_token: - print( - "ERROR: Cannot renew reviewer-bot lock due to token mismatch " - f"(expected prefix={context.lock_token[:8]}, got prefix={str(current_token)[:8]})", - file=bot.sys.stderr, + _log( + bot, + "error", + "Cannot renew reviewer-bot lock due to token mismatch", + expected_prefix=context.lock_token[:8], + actual_prefix=str(current_token)[:8], ) return False desired_lock = bot.build_lock_metadata( @@ -348,61 +440,74 @@ def renew_state_issue_lease_lock(bot: LeaseLockContext, context: LeaseContext) - create_response = bot.create_lock_commit(ref_head_sha, tree_sha, desired_lock) if create_response.status_code != 201: if create_response.status_code == 429 or create_response.status_code >= 500: - delay = retry_base + random.uniform(0, retry_base) - print( - "Retryable lease lock renewal commit failure " - f"(status {create_response.status_code}); retrying ({attempt}/{retry_limit})", - file=bot.sys.stderr, + delay = retry_base + _jitter(bot, 0, retry_base) + _log( + bot, + "warning", + "Retryable lease lock renewal commit failure", + status_code=create_response.status_code, + retry_attempt=attempt, + retry_limit=retry_limit, ) - time.sleep(delay) + _sleep(bot, delay) continue - print( - f"ERROR: Failed to create lock renewal commit (status {create_response.status_code}): {create_response.text}", - file=bot.sys.stderr, + _log( + bot, + "error", + f"Failed to create lock renewal commit (status {create_response.status_code}): {create_response.text}", + status_code=create_response.status_code, ) return False new_commit_sha = extract_commit_sha(create_response.payload) if not new_commit_sha: - print("ERROR: Lock renewal commit response missing SHA", file=bot.sys.stderr) + _log(bot, "error", "Lock renewal commit response missing SHA") return False update_response = bot.cas_update_lock_ref(new_commit_sha) if update_response.status_code == 200: context.lock_expires_at = desired_lock.get("lock_expires_at") - print( - "Renewed reviewer-bot lease lock " - f"(run_id={context.lock_owner_run_id}, token_prefix={context.lock_token[:8]})" + _log( + bot, + "info", + "Renewed reviewer-bot lease lock", + run_id=context.lock_owner_run_id, + token_prefix=context.lock_token[:8], ) return True if update_response.status_code in {409, 422, 429} or update_response.status_code >= 500: - delay = retry_base + random.uniform(0, retry_base) - print( - "Retryable lease lock renewal ref update failure " - f"(status {update_response.status_code}); retrying ({attempt}/{retry_limit})", - file=bot.sys.stderr, + delay = retry_base + _jitter(bot, 0, retry_base) + _log( + bot, + "warning", + "Retryable lease lock renewal ref update failure", + status_code=update_response.status_code, + retry_attempt=attempt, + retry_limit=retry_limit, ) - time.sleep(delay) + _sleep(bot, delay) continue - print( - f"ERROR: Failed to update lock ref during renewal (status {update_response.status_code}): {update_response.text}", - file=bot.sys.stderr, + _log( + bot, + "error", + f"Failed to update lock ref during renewal (status {update_response.status_code}): {update_response.text}", + status_code=update_response.status_code, ) return False - print("ERROR: Exhausted retries while renewing reviewer-bot lease lock", file=bot.sys.stderr) + _log(bot, "error", "Exhausted retries while renewing reviewer-bot lease lock") return False def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: if bot.ACTIVE_LEASE_CONTEXT is not None: return bot.ACTIVE_LEASE_CONTEXT - lock_token = uuid.uuid4().hex - lock_owner_run_id, lock_owner_workflow, lock_owner_job = get_lock_owner_context() - wait_started_at = time.monotonic() + lock_token = _uuid4_hex(bot) + lock_owner_run_id, lock_owner_workflow, lock_owner_job = get_lock_owner_context(bot) + wait_started_at = _monotonic(bot) attempt = 0 - max_wait = getattr(bot, "LOCK_MAX_WAIT_SECONDS", LOCK_MAX_WAIT_SECONDS) - retry_base = getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + max_wait = _lock_max_wait_seconds(bot) + retry_base = _lock_retry_base_seconds(bot) while True: attempt += 1 - elapsed = time.monotonic() - wait_started_at + elapsed = _monotonic(bot) - wait_started_at if elapsed > max_wait: raise RuntimeError( "Timed out waiting for reviewer-bot lease lock " @@ -410,7 +515,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: f"lock_ref={bot.get_lock_ref_display()})" ) ref_head_sha, tree_sha, current_lock = bot.get_lock_ref_snapshot() - now = datetime.now(timezone.utc) + now = _now(bot) lock_valid = bot.lock_is_currently_valid(current_lock, now) if lock_valid and _snapshot_matches_expected_lock(current_lock, lock_token): if not _snapshot_matches_expected_owner( @@ -435,12 +540,15 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: create_response = bot.create_lock_commit(ref_head_sha, tree_sha, desired_lock) if create_response.status_code != 201: if create_response.status_code == 429 or create_response.status_code >= 500: - print( - "Retryable lease lock acquire commit failure " - f"(status {create_response.status_code}); retrying (attempt {attempt})" + _log( + bot, + "warning", + "Retryable lease lock acquire commit failure", + status_code=create_response.status_code, + retry_attempt=attempt, ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) continue if create_response.status_code in {401, 403}: raise RuntimeError( @@ -475,12 +583,15 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: desired_lock.get("lock_expires_at"), ) if _snapshot_is_stale_unlocked_predecessor(snapshot_lock): - print( - "Lease lock acquire visibility lag detected; retrying confirmation " - f"(attempt {attempt}, token_prefix={lock_token[:8]})" + _log( + bot, + "warning", + "Lease lock acquire visibility lag detected; retrying confirmation", + retry_attempt=attempt, + token_prefix=lock_token[:8], ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) continue conflicting_token = snapshot_lock.get("lock_token") if isinstance(snapshot_lock, dict) else None raise RuntimeError( @@ -488,10 +599,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: f"(expected prefix={lock_token[:8]}, got prefix={str(conflicting_token)[:8]})" ) if update_response.status_code in {409, 422}: - print( - "Lease lock acquire conflict " - f"(status {update_response.status_code}); retrying (attempt {attempt})" - ) + _log(bot, "warning", "Lease lock acquire conflict", status_code=update_response.status_code, retry_attempt=attempt) elif update_response.status_code == 404: raise RuntimeError(f"Lock ref {bot.get_lock_ref_display()} not found while acquiring lease lock") elif update_response.status_code in {401, 403}: @@ -500,10 +608,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: f"(status {update_response.status_code}): {update_response.text}" ) elif update_response.status_code == 429 or update_response.status_code >= 500: - print( - "Retryable lease lock acquire failure " - f"(status {update_response.status_code}); retrying (attempt {attempt})" - ) + _log(bot, "warning", "Retryable lease lock acquire failure", status_code=update_response.status_code, retry_attempt=attempt) else: raise RuntimeError( "Unexpected status while acquiring reviewer-bot lease lock " @@ -512,12 +617,16 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: else: lock_owner = current_lock.get("lock_owner_run_id") or "unknown" lock_expires_at = current_lock.get("lock_expires_at") or "unknown" - print( - "Reviewer-bot lease lock currently held by " - f"run_id={lock_owner} until {lock_expires_at}; waiting (lock_ref={bot.get_lock_ref_display()})" + _log( + bot, + "info", + "Reviewer-bot lease lock currently held; waiting", + lock_owner=lock_owner, + lock_expires_at=lock_expires_at, + lock_ref=bot.get_lock_ref_display(), ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: @@ -525,88 +634,109 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: if context is None: return True released = False - retry_limit = getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) - retry_base = getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + retry_limit = _lock_api_retry_limit(bot) + retry_base = _lock_retry_base_seconds(bot) try: for attempt in range(1, retry_limit + 1): try: ref_head_sha, tree_sha, current_lock = bot.get_lock_ref_snapshot() except RuntimeError as exc: - print(f"ERROR: Failed to read lock snapshot while releasing lock: {exc}", file=bot.sys.stderr) + _log(bot, "error", f"Failed to read lock snapshot while releasing lock: {exc}") break current_token = current_lock.get("lock_token") if current_token != context.lock_token: if _snapshot_is_stale_unlocked_predecessor(current_lock): - print( - "Lease lock release observed stale unlocked predecessor; retrying " - f"(attempt {attempt}, token_prefix={context.lock_token[:8]})", - file=bot.sys.stderr, + _log( + bot, + "warning", + "Lease lock release observed stale unlocked predecessor; retrying", + retry_attempt=attempt, + token_prefix=context.lock_token[:8], ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) continue - print( - "WARNING: Lease lock token mismatch during release; " - f"expected prefix={context.lock_token[:8]}, got prefix={str(current_token)[:8]}", - file=bot.sys.stderr, + _log( + bot, + "warning", + "Lease lock token mismatch during release", + expected_prefix=context.lock_token[:8], + actual_prefix=str(current_token)[:8], ) return False create_response = bot.create_lock_commit(ref_head_sha, tree_sha, bot.clear_lock_metadata()) if create_response.status_code != 201: if create_response.status_code in {429} or create_response.status_code >= 500: - print( - "Retryable lease lock release commit failure " - f"(status {create_response.status_code}); retrying ({attempt}/{retry_limit})", - file=bot.sys.stderr, + _log( + bot, + "warning", + "Retryable lease lock release commit failure", + status_code=create_response.status_code, + retry_attempt=attempt, + retry_limit=retry_limit, ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) continue - print( - f"ERROR: Failed to create lock release commit (status {create_response.status_code}): {create_response.text}", - file=bot.sys.stderr, + _log( + bot, + "error", + f"Failed to create lock release commit (status {create_response.status_code}): {create_response.text}", + status_code=create_response.status_code, ) break new_commit_sha = extract_commit_sha(create_response.payload) if not new_commit_sha: - print("ERROR: Lock release commit response missing SHA", file=bot.sys.stderr) + _log(bot, "error", "Lock release commit response missing SHA") break update_response = bot.cas_update_lock_ref(new_commit_sha) if update_response.status_code == 200: released = True - print( - "Released reviewer-bot lease lock " - f"(run_id={context.lock_owner_run_id}, token_prefix={context.lock_token[:8]}, lock_ref={bot.get_lock_ref_display()})" + _log( + bot, + "info", + "Released reviewer-bot lease lock", + run_id=context.lock_owner_run_id, + token_prefix=context.lock_token[:8], + lock_ref=bot.get_lock_ref_display(), ) return True if update_response.status_code in {409, 422, 429} or update_response.status_code >= 500: - print( - "Retryable lease lock release failure " - f"(status {update_response.status_code}); retrying ({attempt}/{retry_limit})", - file=bot.sys.stderr, + _log( + bot, + "warning", + "Retryable lease lock release failure", + status_code=update_response.status_code, + retry_attempt=attempt, + retry_limit=retry_limit, ) - delay = retry_base + random.uniform(0, retry_base) - time.sleep(delay) + delay = retry_base + _jitter(bot, 0, retry_base) + _sleep(bot, delay) continue if update_response.status_code in {401, 403, 404}: - print( - "ERROR: Hard failure releasing reviewer-bot lease lock " - f"(status {update_response.status_code}): {update_response.text}", - file=bot.sys.stderr, + _log( + bot, + "error", + f"Hard failure releasing reviewer-bot lease lock (status {update_response.status_code}): {update_response.text}", + status_code=update_response.status_code, ) break - print( - "ERROR: Unexpected status while releasing reviewer-bot lease lock " - f"(status {update_response.status_code}): {update_response.text}", - file=bot.sys.stderr, + _log( + bot, + "error", + f"Unexpected status while releasing reviewer-bot lease lock (status {update_response.status_code}): {update_response.text}", + status_code=update_response.status_code, ) break return False finally: if not released: - print( - "ERROR: Lease lock release failed " - f"(run_id={context.lock_owner_run_id}, token_prefix={context.lock_token[:8]}, state_issue_url={context.state_issue_url})", - file=bot.sys.stderr, + _log( + bot, + "error", + "Lease lock release failed", + run_id=context.lock_owner_run_id, + token_prefix=context.lock_token[:8], + state_issue_url=context.state_issue_url, ) bot.ACTIVE_LEASE_CONTEXT = None diff --git a/tests/unit/reviewer_bot/test_lease_lock.py b/tests/unit/reviewer_bot/test_lease_lock.py index 3dab33b15..b5f6a69d3 100644 --- a/tests/unit/reviewer_bot/test_lease_lock.py +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -5,6 +5,11 @@ from scripts.reviewer_bot_lib import lease_lock, state_store from scripts.reviewer_bot_lib.config import GitHubApiResult, LeaseContext +from tests.fixtures.fake_clock import FakeClock +from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_sleeper import RecordingSleeper +from tests.fixtures.fake_uuid import FixedUuidSource +from tests.fixtures.recording_logger import RecordingLogger def _lease_bot(**overrides): @@ -15,9 +20,17 @@ def _lease_bot(**overrides): LOCK_API_RETRY_LIMIT=3, LOCK_RETRY_BASE_SECONDS=0, LOCK_MAX_WAIT_SECONDS=60, + LOCK_RENEWAL_WINDOW_SECONDS=60, sys=sys, + time=SimpleNamespace(monotonic=lambda: 0.0), + clock=FakeClock(), + sleeper=RecordingSleeper(), + jitter=DeterministicJitter(0.0), + uuid_source=FixedUuidSource("token-123"), + logger=RecordingLogger(), github_api_request=lambda *args, **kwargs: None, get_state_issue_snapshot=lambda: SimpleNamespace(html_url="https://example.com/issues/314"), + get_config_value=lambda name, default="": default, normalize_lock_metadata=state_store.normalize_lock_metadata, parse_iso8601_timestamp=state_store.parse_iso8601_timestamp, ) @@ -43,7 +56,7 @@ def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): monkeypatch.setattr( lease_lock, "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), ) snapshots = iter( [ @@ -65,8 +78,6 @@ def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): ] ) - monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) bot.get_lock_ref_snapshot = lambda: next(snapshots) bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( 201, {"sha": "commit-1"}, {}, "", True @@ -88,8 +99,6 @@ def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): ] ) - monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) bot.get_lock_ref_snapshot = lambda: next(snapshots) bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( 201, {"sha": "commit-1"}, {}, "", True @@ -105,7 +114,7 @@ def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypa monkeypatch.setattr( lease_lock, "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), ) snapshots = iter( [ @@ -126,8 +135,6 @@ def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypa ] ) - monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) bot.get_lock_ref_snapshot = lambda: next(snapshots) bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( 201, {"sha": "commit-1"}, {}, "", True @@ -145,7 +152,7 @@ def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypat monkeypatch.setattr( lease_lock, "get_lock_owner_context", - lambda: ("local-run", "reviewer-bot", "reviewer-bot"), + lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), ) snapshots = iter( [ @@ -164,8 +171,6 @@ def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypat ] ) - monkeypatch.setattr(lease_lock.uuid, "uuid4", lambda: type("U", (), {"hex": "token-123"})()) - monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) bot.get_lock_ref_snapshot = lambda: next(snapshots) with pytest.raises(RuntimeError, match="owner metadata drifted"): @@ -190,7 +195,6 @@ def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): ] ) - monkeypatch.setattr(lease_lock.time, "sleep", lambda seconds: None) bot.get_lock_ref_snapshot = lambda: next(snapshots) bot.create_lock_commit = lambda parent_sha, tree_sha, lock_meta: GitHubApiResult( 201, {"sha": "commit-2"}, {}, "", True From 7130ce406849ae91a4451eb1024140fd71366fc1 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:53:01 +0900 Subject: [PATCH 128/209] refactor(reviewer-bot): inject rest transport into github api helpers --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 2 +- scripts/reviewer_bot_lib/github_api.py | 16 ++- scripts/reviewer_bot_lib/runtime.py | 18 ++++ tests/unit/reviewer_bot/test_github_api.py | 102 ++++++++---------- 4 files changed, 77 insertions(+), 61 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 3f0bd4abc..0fb118878 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -61,7 +61,7 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ) adapters = SimpleNamespace( assert_lock_held=lambda context: state_store.assert_lock_held(runtime, context), - get_github_token=github_api.get_github_token, + get_github_token=lambda: github_api.get_github_token(runtime), get_github_graphql_token=lambda *, prefer_board_token=False: github_api.get_github_graphql_token( runtime, prefer_board_token=prefer_board_token ), diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 98c7172c3..fd5445f88 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -93,8 +93,8 @@ def _build_result( ) -def get_github_token() -> str: - token = os.environ.get("GITHUB_TOKEN") +def get_github_token(bot: GitHubTransportContext) -> str: + token = bot.get_config_value("GITHUB_TOKEN") if not token: print("ERROR: GITHUB_TOKEN not set", file=sys.stderr) raise SystemExit(1) @@ -103,7 +103,7 @@ def get_github_token() -> str: def get_github_graphql_token(bot: GitHubTransportContext, *, prefer_board_token: bool = False) -> str: if prefer_board_token: - token = os.environ.get(REVIEWER_BOARD_TOKEN_ENV) + token = bot.get_config_value(REVIEWER_BOARD_TOKEN_ENV) if not token: raise RuntimeError(f"{REVIEWER_BOARD_TOKEN_ENV} not set") return token @@ -123,7 +123,7 @@ def github_api_request( ): _validate_rest_retry_policy(method, retry_policy) token = bot.get_github_token() - repo = f"{os.environ['REPO_OWNER']}/{os.environ['REPO_NAME']}" + repo = f"{bot.get_config_value('REPO_OWNER')}/{bot.get_config_value('REPO_NAME')}" url = f"https://api.github.com/repos/{repo}/{endpoint}" headers = { @@ -138,7 +138,13 @@ def github_api_request( max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) for attempt in range(1, max_attempts + 1): try: - response = requests.request(method, url, headers=headers, json=data, timeout=timeout_seconds) + response = bot.rest_transport.request( + method, + url, + headers=headers, + json_data=data, + timeout_seconds=timeout_seconds, + ) except requests.RequestException as exc: failure_kind = _classify_failure(None, transport_error=True) if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and attempt < max_attempts: diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index b1dc74901..bc0504210 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -120,6 +120,22 @@ def event(self, level: str, message: str, **fields: Any) -> None: self._sys.stderr.write(f"[{level}] {message}{suffix}\n") +class RequestsRestTransport: + def __init__(self, requests_module: Any): + self._requests = requests_module + + def request( + self, + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + json_data: dict[str, Any] | None = None, + timeout_seconds: float | None = None, + ) -> Any: + return self._requests.request(method, url, headers=headers, json=json_data, timeout=timeout_seconds) + + class ReviewerBotRuntime: """Runtime object built from explicit services and named adapters.""" @@ -152,6 +168,7 @@ def __init__( config: Any | None = None, outputs: Any | None = None, deferred_payloads: Any | None = None, + rest_transport: Any | None = None, clock: Any | None = None, sleeper: Any | None = None, jitter: Any | None = None, @@ -172,6 +189,7 @@ def __init__( self.config = config or _EnvConfig() self.outputs = outputs or _FileOutputSink(self.config) self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) + self.rest_transport = rest_transport or RequestsRestTransport(requests) self.clock = clock or SystemClock() self.sleeper = sleeper or SystemSleeper(time) self.jitter = jitter or RandomJitterSource(random) diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 974df3092..4a857b590 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -7,12 +7,34 @@ from tests.fixtures.http_responses import FakeGitHubResponse +class RecordingRestTransport: + def __init__(self, responses=None): + self._responses = iter(responses or []) + self.calls = [] + + def request(self, method, url, *, headers=None, json_data=None, timeout_seconds=None): + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json_data": json_data, + "timeout_seconds": timeout_seconds, + } + ) + response = next(self._responses) + if isinstance(response, Exception): + raise response + return response + + def _bot(**overrides): bot = SimpleNamespace( GitHubApiResult=GitHubApiResult, get_github_token=lambda: "token", get_github_graphql_token=lambda prefer_board_token=False: "token", get_config_value=lambda name, default="": __import__("os").environ.get(name, default), + rest_transport=RecordingRestTransport(), STATE_ISSUE_NUMBER=1, ) for key, value in overrides.items(): @@ -23,16 +45,15 @@ def _bot(**overrides): def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( + transport = RecordingRestTransport( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: next(responses)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -43,18 +64,12 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - if calls["count"] == 1: - raise github_api.requests.RequestException("timeout") - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(github_api.requests, "request", fake_request) + transport = RecordingRestTransport( + [github_api.requests.RequestException("timeout"), FakeGitHubResponse(200, {"ok": True}, "ok")] + ) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -64,39 +79,27 @@ def fake_request(*args, **kwargs): def test_github_api_request_classifies_not_found_without_retry(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} - - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(404, {"message": "missing"}, "missing") - - monkeypatch.setattr(github_api.requests, "request", fake_request) + transport = RecordingRestTransport([FakeGitHubResponse(404, {"message": "missing"}, "missing")]) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "not_found" assert result.retry_attempts == 0 - assert calls["count"] == 1 + assert len(transport.calls) == 1 def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - calls = {"count": 0} + transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) - def fake_request(*args, **kwargs): - calls["count"] += 1 - return FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden") - - monkeypatch.setattr(github_api.requests, "request", fake_request) - - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "forbidden" assert result.retry_attempts == 0 - assert calls["count"] == 1 + assert len(transport.calls) == 1 def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): @@ -119,16 +122,15 @@ def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - responses = iter( + transport = RecordingRestTransport( [ FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: next(responses)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.retry_attempts == 1 @@ -137,10 +139,10 @@ def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down")) + transport = RecordingRestTransport([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * (LOCK_API_RETRY_LIMIT + 1)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "rate_limited" @@ -168,18 +170,12 @@ def test_github_graphql_request_reports_graphql_errors(monkeypatch): def test_github_api_request_passes_timeout(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - observed = {} + transport = RecordingRestTransport([FakeGitHubResponse(200, {"ok": True}, "ok")]) - def fake_request(method, url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"ok": True}, "ok") - - monkeypatch.setattr(github_api.requests, "request", fake_request) - - result = github_api.github_api_request(_bot(), "GET", "issues/42", timeout_seconds=12.5) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", timeout_seconds=12.5) assert result.ok is True - assert observed["timeout"] == 12.5 + assert transport.calls[0]["timeout_seconds"] == 12.5 def test_github_api_request_rejects_idempotent_retry_for_non_get(): @@ -233,9 +229,9 @@ def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) + transport = RecordingRestTransport([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) - result = github_api.github_api_request(_bot(), "GET", "issues/42") + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42") assert result.ok is False assert result.failure_kind == "invalid_payload" @@ -244,10 +240,10 @@ def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypat def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - monkeypatch.setattr(github_api.requests, "request", lambda *args, **kwargs: FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway")) + transport = RecordingRestTransport([FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway")] * (LOCK_API_RETRY_LIMIT + 1)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "server_error" @@ -257,14 +253,10 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): monkeypatch.setenv("REPO_OWNER", "rustfoundation") monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") - - def always_fail(*args, **kwargs): - raise github_api.requests.RequestException("timeout") - - monkeypatch.setattr(github_api.requests, "request", always_fail) + transport = RecordingRestTransport([github_api.requests.RequestException("timeout")] * (LOCK_API_RETRY_LIMIT + 1)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "transport_error" From 32ae81d813548bc601e5a0a5ce763b55fc74d968 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:56:01 +0900 Subject: [PATCH 129/209] refactor(reviewer-bot): inject graphql transport into github api helpers --- scripts/reviewer_bot_lib/github_api.py | 7 +-- scripts/reviewer_bot_lib/runtime.py | 23 +++++++++ .../reviewer_bot/test_adapter_contract.py | 6 +++ tests/unit/reviewer_bot/test_github_api.py | 51 +++++++++++++------ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index fd5445f88..ea809e8fe 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -240,11 +240,12 @@ def github_graphql_request( max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) for attempt in range(1, max_attempts + 1): try: - response = requests.post( + response = bot.graphql_transport.query( "https://api.github.com/graphql", headers=headers, - json={"query": query, "variables": variables or {}}, - timeout=timeout_seconds, + query=query, + variables=variables, + timeout_seconds=timeout_seconds, ) except requests.RequestException as exc: failure_kind = _classify_failure(None, transport_error=True) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index bc0504210..41d89f857 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -136,6 +136,27 @@ def request( return self._requests.request(method, url, headers=headers, json=json_data, timeout=timeout_seconds) +class RequestsGraphQLTransport: + def __init__(self, requests_module: Any): + self._requests = requests_module + + def query( + self, + url: str, + *, + headers: dict[str, str] | None = None, + query: str, + variables: dict[str, Any] | None = None, + timeout_seconds: float | None = None, + ) -> Any: + return self._requests.post( + url, + headers=headers, + json={"query": query, "variables": variables or {}}, + timeout=timeout_seconds, + ) + + class ReviewerBotRuntime: """Runtime object built from explicit services and named adapters.""" @@ -169,6 +190,7 @@ def __init__( outputs: Any | None = None, deferred_payloads: Any | None = None, rest_transport: Any | None = None, + graphql_transport: Any | None = None, clock: Any | None = None, sleeper: Any | None = None, jitter: Any | None = None, @@ -190,6 +212,7 @@ def __init__( self.outputs = outputs or _FileOutputSink(self.config) self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) self.rest_transport = rest_transport or RequestsRestTransport(requests) + self.graphql_transport = graphql_transport or RequestsGraphQLTransport(requests) self.clock = clock or SystemClock() self.sleeper = sleeper or SystemSleeper(time) self.jitter = jitter or RandomJitterSource(random) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 34a6d0e86..3ab7bb1e5 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -238,12 +238,16 @@ def test_runtime_accepts_injected_infra_services(): jitter = object() uuid_source = object() logger = object() + rest_transport = object() + graphql_transport = object() runtime = ReviewerBotRuntime( requests=SimpleNamespace(), sys=SimpleNamespace(stderr=SimpleNamespace(write=lambda _text: None)), random=SimpleNamespace(uniform=lambda lower, upper: lower), time=SimpleNamespace(sleep=lambda _seconds: None), + rest_transport=rest_transport, + graphql_transport=graphql_transport, clock=clock, sleeper=sleeper, jitter=jitter, @@ -261,3 +265,5 @@ def test_runtime_accepts_injected_infra_services(): assert runtime.jitter is jitter assert runtime.uuid_source is uuid_source assert runtime.logger is logger + assert runtime.rest_transport is rest_transport + assert runtime.graphql_transport is graphql_transport diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 4a857b590..0aa6fb78c 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -28,6 +28,27 @@ def request(self, method, url, *, headers=None, json_data=None, timeout_seconds= return response +class RecordingGraphQLTransport: + def __init__(self, responses=None): + self._responses = iter(responses or []) + self.calls = [] + + def query(self, url, *, headers=None, query=None, variables=None, timeout_seconds=None): + self.calls.append( + { + "url": url, + "headers": headers, + "query": query, + "variables": variables, + "timeout_seconds": timeout_seconds, + } + ) + response = next(self._responses) + if isinstance(response, Exception): + raise response + return response + + def _bot(**overrides): bot = SimpleNamespace( GitHubApiResult=GitHubApiResult, @@ -35,6 +56,7 @@ def _bot(**overrides): get_github_graphql_token=lambda prefer_board_token=False: "token", get_config_value=lambda name, default="": __import__("os").environ.get(name, default), rest_transport=RecordingRestTransport(), + graphql_transport=RecordingGraphQLTransport(), STATE_ISSUE_NUMBER=1, ) for key, value in overrides.items(): @@ -103,16 +125,19 @@ def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - responses = iter( + transport = RecordingGraphQLTransport( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), ] ) - monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: next(responses)) monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_graphql_request(_bot(), "query { viewer { login } }", retry_policy="idempotent_read") + result = github_api.github_graphql_request( + _bot(graphql_transport=transport), + "query { viewer { login } }", + retry_policy="idempotent_read", + ) assert result.ok is True assert result.payload == {"data": {"viewer": {"login": "bot"}}} @@ -150,18 +175,18 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch def test_github_graphql_request_reports_invalid_payload(monkeypatch): - monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) + transport = RecordingGraphQLTransport([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) - result = github_api.github_graphql_request(_bot(), "query { viewer { login } }") + result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_graphql_request_reports_graphql_errors(monkeypatch): - monkeypatch.setattr(github_api.requests, "post", lambda *args, **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")) + transport = RecordingGraphQLTransport([FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")]) - result = github_api.github_graphql_request(_bot(), "query { viewer { login } }") + result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" @@ -265,18 +290,12 @@ def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): def test_github_graphql_request_passes_timeout(monkeypatch): - observed = {} - - def fake_post(url, headers=None, json=None, timeout=None): - observed["timeout"] = timeout - return FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok") - - monkeypatch.setattr(github_api.requests, "post", fake_post) + transport = RecordingGraphQLTransport([FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")]) - result = github_api.github_graphql_request(_bot(), "query { viewer { login } }", timeout_seconds=9.5) + result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }", timeout_seconds=9.5) assert result.ok is True - assert observed["timeout"] == 9.5 + assert transport.calls[0]["timeout_seconds"] == 9.5 def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): From 21042964035094956544a0ed2452bdb6176fed6e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 21:58:18 +0900 Subject: [PATCH 130/209] refactor(reviewer-bot): add shared retry policy primitives --- scripts/reviewer_bot_lib/retrying.py | 48 ++++++++++++++++++++++++ tests/unit/reviewer_bot/test_retrying.py | 39 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 scripts/reviewer_bot_lib/retrying.py create mode 100644 tests/unit/reviewer_bot/test_retrying.py diff --git a/scripts/reviewer_bot_lib/retrying.py b/scripts/reviewer_bot_lib/retrying.py new file mode 100644 index 000000000..9b65ed04e --- /dev/null +++ b/scripts/reviewer_bot_lib/retrying.py @@ -0,0 +1,48 @@ +"""Shared retry helpers for reviewer-bot transport and state workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +RETRY_POLICY_NONE = "none" +RETRY_POLICY_IDEMPOTENT_READ = "idempotent_read" + + +class JitterSource(Protocol): + def uniform(self, lower: float, upper: float) -> float: ... + + +@dataclass(frozen=True) +class RetrySpec: + retry_policy: str + max_attempts: int + base_delay_seconds: float + max_delay_seconds: float = 8.0 + + +def is_retryable_status(status_code: int | None) -> bool: + return status_code == 429 or (status_code is not None and status_code >= 500) + + +def additional_attempts_for_policy(retry_policy: str, retry_limit: int) -> int: + if retry_policy == RETRY_POLICY_NONE: + return 0 + if retry_policy == RETRY_POLICY_IDEMPOTENT_READ: + return retry_limit + raise ValueError(f"Unsupported retry policy: {retry_policy}") + + +def max_attempts_for_policy(retry_policy: str, retry_limit: int) -> int: + return 1 + additional_attempts_for_policy(retry_policy, retry_limit) + + +def bounded_exponential_delay( + base_delay_seconds: float, + retry_attempt: int, + *, + jitter: JitterSource, + max_delay_seconds: float = 8.0, +) -> float: + bounded_base = min(base_delay_seconds * (2 ** max(retry_attempt - 1, 0)), max_delay_seconds) + return bounded_base + jitter.uniform(0, bounded_base) diff --git a/tests/unit/reviewer_bot/test_retrying.py b/tests/unit/reviewer_bot/test_retrying.py new file mode 100644 index 000000000..877cab522 --- /dev/null +++ b/tests/unit/reviewer_bot/test_retrying.py @@ -0,0 +1,39 @@ +from scripts.reviewer_bot_lib import retrying +from tests.fixtures.fake_jitter import DeterministicJitter + + +def test_is_retryable_status_matches_expected_statuses(): + assert retrying.is_retryable_status(429) is True + assert retrying.is_retryable_status(500) is True + assert retrying.is_retryable_status(503) is True + assert retrying.is_retryable_status(404) is False + assert retrying.is_retryable_status(403) is False + assert retrying.is_retryable_status(None) is False + + +def test_additional_attempts_for_policy_supports_known_policies(): + assert retrying.additional_attempts_for_policy(retrying.RETRY_POLICY_NONE, 5) == 0 + assert retrying.additional_attempts_for_policy(retrying.RETRY_POLICY_IDEMPOTENT_READ, 5) == 5 + + +def test_additional_attempts_for_policy_rejects_unknown_policy(): + try: + retrying.additional_attempts_for_policy("unexpected", 3) + except ValueError as exc: + assert "Unsupported retry policy" in str(exc) + else: + raise AssertionError("expected ValueError for unsupported retry policy") + + +def test_max_attempts_for_policy_includes_initial_attempt(): + assert retrying.max_attempts_for_policy(retrying.RETRY_POLICY_NONE, 4) == 1 + assert retrying.max_attempts_for_policy(retrying.RETRY_POLICY_IDEMPOTENT_READ, 4) == 5 + + +def test_bounded_exponential_delay_uses_jitter_and_caps_growth(): + jitter = DeterministicJitter([0.25, 0.5, 0.75]) + + assert retrying.bounded_exponential_delay(2.0, 1, jitter=jitter) == 2.25 + assert retrying.bounded_exponential_delay(2.0, 2, jitter=jitter) == 4.5 + assert retrying.bounded_exponential_delay(2.0, 4, jitter=jitter) == 8.75 + assert jitter.calls == [(0, 2.0), (0, 4.0), (0, 8.0)] From 3eb241a06ec0a3015959b5cc6c222193993b0864 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:03:27 +0900 Subject: [PATCH 131/209] refactor(reviewer-bot): share rest retry policy logic --- scripts/reviewer_bot_lib/github_api.py | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index ea809e8fe..bfb19811a 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -9,6 +9,7 @@ import requests +from . import retrying from .config import ( LOCK_API_RETRY_LIMIT, LOCK_RETRY_BASE_SECONDS, @@ -17,12 +18,17 @@ ) from .context import GitHubTransportContext -RETRY_POLICY_NONE = "none" -RETRY_POLICY_IDEMPOTENT_READ = "idempotent_read" +RETRY_POLICY_NONE = retrying.RETRY_POLICY_NONE +RETRY_POLICY_IDEMPOTENT_READ = retrying.RETRY_POLICY_IDEMPOTENT_READ + + +class _RandomJitter: + def uniform(self, lower: float, upper: float) -> float: + return __import__("random").uniform(lower, upper) def _should_retry_status(status_code: int | None) -> bool: - return status_code == 429 or (status_code is not None and status_code >= 500) + return retrying.is_retryable_status(status_code) def _classify_failure(status_code: int | None, *, invalid_payload: bool = False, transport_error: bool = False) -> str | None: @@ -46,24 +52,25 @@ def _classify_failure(status_code: int | None, *, invalid_payload: bool = False, def _retry_delay(base_seconds: float, retry_attempt: int) -> float: - bounded_base = min(base_seconds * (2 ** max(retry_attempt - 1, 0)), 8.0) - return bounded_base + random.uniform(0, bounded_base) + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=_RandomJitter(), + ) def _validate_rest_retry_policy(method: str, retry_policy: str) -> None: + retrying.additional_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) if retry_policy == RETRY_POLICY_NONE: return - if retry_policy != RETRY_POLICY_IDEMPOTENT_READ: - raise ValueError(f"Unsupported retry policy: {retry_policy}") if method.upper() != "GET": raise ValueError("idempotent_read retry policy is only valid for REST GET requests") def _validate_graphql_retry_policy(query: str, retry_policy: str) -> None: + retrying.additional_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) if retry_policy == RETRY_POLICY_NONE: return - if retry_policy != RETRY_POLICY_IDEMPOTENT_READ: - raise ValueError(f"Unsupported retry policy: {retry_policy}") stripped = query.lstrip() if stripped.startswith("mutation"): raise ValueError("idempotent_read retry policy is only valid for GraphQL queries") @@ -135,7 +142,7 @@ def github_api_request( headers.update(extra_headers) retry_attempts = 0 - max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) + max_attempts = retrying.max_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) for attempt in range(1, max_attempts + 1): try: response = bot.rest_transport.request( @@ -237,7 +244,7 @@ def github_graphql_request( } retry_attempts = 0 - max_attempts = 1 + (LOCK_API_RETRY_LIMIT if retry_policy == RETRY_POLICY_IDEMPOTENT_READ else 0) + max_attempts = retrying.max_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) for attempt in range(1, max_attempts + 1): try: response = bot.graphql_transport.query( From a983503216150ab85b4b889a89a51ff2c43b7d1a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:05:23 +0900 Subject: [PATCH 132/209] refactor(reviewer-bot): share graphql retry policy logic --- scripts/reviewer_bot_lib/github_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index bfb19811a..8e89f11fa 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -154,7 +154,7 @@ def github_api_request( ) except requests.RequestException as exc: failure_kind = _classify_failure(None, transport_error=True) - if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and attempt < max_attempts: + if attempt < max_attempts: retry_attempts += 1 time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue @@ -301,7 +301,7 @@ def github_graphql_request( response.status_code, invalid_payload=invalid_payload or bool(graphql_errors), ) - if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and _should_retry_status(response.status_code) and attempt < max_attempts: + if _should_retry_status(response.status_code) and attempt < max_attempts: retry_attempts += 1 time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue From d69452162c641c5842ce12fb8eea737dc8bdee10 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:07:46 +0900 Subject: [PATCH 133/209] refactor(reviewer-bot): share retry helpers in state store workflows --- scripts/reviewer_bot_lib/state_store.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 6be9f2324..57306b45e 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -7,6 +7,7 @@ import yaml +from . import retrying from .config import ( FRESHNESS_RUNTIME_EPOCH_LEGACY, LOCK_API_RETRY_LIMIT, @@ -54,6 +55,18 @@ def _jitter(bot: StateStoreContext, lower: float, upper: float) -> float: return __import__("random").uniform(lower, upper) +def _retry_delay(bot: StateStoreContext, base_seconds: float, retry_attempt: int) -> float: + class _BotJitter: + def uniform(self, lower: float, upper: float) -> float: + return _jitter(bot, lower, upper) + + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=_BotJitter(), + ) + + def _now_iso(bot: StateStoreContext) -> str: clock = getattr(bot, "clock", None) if clock is not None and hasattr(clock, "now"): @@ -117,9 +130,9 @@ def get_state_issue(bot: StateStoreContext) -> dict | None: ) return None - if response.status_code == 429 or response.status_code >= 500: + if retrying.is_retryable_status(response.status_code): if attempt < state_read_retry_limit: - delay = state_read_retry_base_seconds + _jitter(bot, 0, state_read_retry_base_seconds) + delay = _retry_delay(bot, state_read_retry_base_seconds, attempt) _log( bot, "warning", @@ -470,7 +483,7 @@ def save_state(bot: StateStoreContext, state: dict) -> bool: status_code=response.status_code, retry_attempt=attempt, ) - delay = lock_retry_base_seconds + _jitter(bot, 0, lock_retry_base_seconds) + delay = _retry_delay(bot, lock_retry_base_seconds, attempt) _sleep(bot, delay) continue @@ -488,9 +501,9 @@ def save_state(bot: StateStoreContext, state: dict) -> bool: ) return False - if response.status_code == 429 or response.status_code >= 500: + if retrying.is_retryable_status(response.status_code): if attempt < lock_api_retry_limit: - delay = lock_retry_base_seconds + _jitter(bot, 0, lock_retry_base_seconds) + delay = _retry_delay(bot, lock_retry_base_seconds, attempt) _log( bot, "warning", From fc29325ba060cd7fb50e62ae01d1809545c12971 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:10:30 +0900 Subject: [PATCH 134/209] refactor(reviewer-bot): share retry helpers in lease lock workflows --- scripts/reviewer_bot_lib/lease_lock.py | 41 +++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index e2f9840a7..488c02a52 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from typing import Any +from . import retrying from .config import ( LOCK_API_RETRY_LIMIT, LOCK_COMMIT_MARKER, @@ -42,6 +43,18 @@ def _jitter(bot: LeaseLockContext, lower: float, upper: float) -> float: return __import__("random").uniform(lower, upper) +def _retry_delay(bot: LeaseLockContext, base_seconds: float, retry_attempt: int) -> float: + class _BotJitter: + def uniform(self, lower: float, upper: float) -> float: + return _jitter(bot, lower, upper) + + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=_BotJitter(), + ) + + def _now(bot: LeaseLockContext) -> datetime: clock = getattr(bot, "clock", None) if clock is not None and hasattr(clock, "now"): @@ -439,8 +452,8 @@ def renew_state_issue_lease_lock(bot: LeaseLockContext, context: LeaseContext) - ) create_response = bot.create_lock_commit(ref_head_sha, tree_sha, desired_lock) if create_response.status_code != 201: - if create_response.status_code == 429 or create_response.status_code >= 500: - delay = retry_base + _jitter(bot, 0, retry_base) + if retrying.is_retryable_status(create_response.status_code): + delay = _retry_delay(bot, retry_base, attempt) _log( bot, "warning", @@ -473,8 +486,8 @@ def renew_state_issue_lease_lock(bot: LeaseLockContext, context: LeaseContext) - token_prefix=context.lock_token[:8], ) return True - if update_response.status_code in {409, 422, 429} or update_response.status_code >= 500: - delay = retry_base + _jitter(bot, 0, retry_base) + if update_response.status_code in {409, 422} or retrying.is_retryable_status(update_response.status_code): + delay = _retry_delay(bot, retry_base, attempt) _log( bot, "warning", @@ -539,7 +552,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: ) create_response = bot.create_lock_commit(ref_head_sha, tree_sha, desired_lock) if create_response.status_code != 201: - if create_response.status_code == 429 or create_response.status_code >= 500: + if retrying.is_retryable_status(create_response.status_code): _log( bot, "warning", @@ -547,7 +560,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: status_code=create_response.status_code, retry_attempt=attempt, ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) continue if create_response.status_code in {401, 403}: @@ -590,7 +603,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: retry_attempt=attempt, token_prefix=lock_token[:8], ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) continue conflicting_token = snapshot_lock.get("lock_token") if isinstance(snapshot_lock, dict) else None @@ -607,7 +620,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: "Insufficient permission to acquire reviewer-bot lease lock " f"(status {update_response.status_code}): {update_response.text}" ) - elif update_response.status_code == 429 or update_response.status_code >= 500: + elif retrying.is_retryable_status(update_response.status_code): _log(bot, "warning", "Retryable lease lock acquire failure", status_code=update_response.status_code, retry_attempt=attempt) else: raise RuntimeError( @@ -625,7 +638,7 @@ def acquire_state_issue_lease_lock(bot: LeaseLockContext) -> LeaseContext: lock_expires_at=lock_expires_at, lock_ref=bot.get_lock_ref_display(), ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) @@ -653,7 +666,7 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: retry_attempt=attempt, token_prefix=context.lock_token[:8], ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) continue _log( @@ -666,7 +679,7 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: return False create_response = bot.create_lock_commit(ref_head_sha, tree_sha, bot.clear_lock_metadata()) if create_response.status_code != 201: - if create_response.status_code in {429} or create_response.status_code >= 500: + if retrying.is_retryable_status(create_response.status_code): _log( bot, "warning", @@ -675,7 +688,7 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: retry_attempt=attempt, retry_limit=retry_limit, ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) continue _log( @@ -701,7 +714,7 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: lock_ref=bot.get_lock_ref_display(), ) return True - if update_response.status_code in {409, 422, 429} or update_response.status_code >= 500: + if update_response.status_code in {409, 422} or retrying.is_retryable_status(update_response.status_code): _log( bot, "warning", @@ -710,7 +723,7 @@ def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: retry_attempt=attempt, retry_limit=retry_limit, ) - delay = retry_base + _jitter(bot, 0, retry_base) + delay = _retry_delay(bot, retry_base, attempt) _sleep(bot, delay) continue if update_response.status_code in {401, 403, 404}: From 241aeb6efaeb8a1ad841bd2196fa3407d82fe2f0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:14:45 +0900 Subject: [PATCH 135/209] refactor(reviewer-bot): route command defaults through runtime-backed requests --- scripts/reviewer_bot_lib/commands.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 366979984..6fdb3bc66 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -143,7 +143,7 @@ def handle_pass_command( reason: str | None, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) issue_data = review_state.ensure_review_entry(state, issue_number, create=True) if issue_data is None: return "❌ Unable to load review state.", False @@ -198,7 +198,7 @@ def handle_pass_until_command( reason: str | None, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) try: parsed_date = datetime.strptime(return_date, "%Y-%m-%d").date() except ValueError: @@ -277,7 +277,7 @@ def handle_label_command( label_string: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) pattern = r'(?:(?<=^)|(?<=\s))([+-])(.+?)(?=\s[+-]|\s*$)' matches = re.findall(pattern, label_string) if not matches: @@ -339,7 +339,7 @@ def resolve_workflow_run_pr_number( bot, request: PrivilegedCommandRequest | None = None, ) -> int: - privileged_request = request or decode_privileged_command_request(bot, issue_number=0) + privileged_request = request or build_privileged_command_request(bot, issue_number=0) if privileged_request.workflow_run_reconcile_pr_number is None: raise RuntimeError("Missing WORKFLOW_RUN_RECONCILE_PR_NUMBER in workflow_run reconcile context") pr_number = privileged_request.workflow_run_reconcile_pr_number @@ -384,7 +384,7 @@ def handle_queue_command( state: dict, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=0) + assignment_request = request or build_assignment_request(bot, issue_number=0) queue_size = len(state["queue"]) repo_owner = assignment_request.repo_owner repo_name = assignment_request.repo_name @@ -421,7 +421,7 @@ def handle_claim_command( comment_author: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) is_producer = any(member["github"].lower() == comment_author.lower() for member in state["queue"]) is_away = any(member["github"].lower() == comment_author.lower() for member in state.get("pass_until", [])) if not is_producer and not is_away: @@ -457,7 +457,7 @@ def handle_release_command( request: AssignmentRequest | None = None, ) -> tuple[str, bool]: args = args or [] - request = request or decode_assignment_request(bot, issue_number=issue_number) + request = request or build_assignment_request(bot, issue_number=issue_number) target_username = None reason = None releasing_other = False @@ -516,7 +516,7 @@ def handle_assign_command( username: str, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) username = username.lstrip("@") if not username: return (f"❌ Missing username. Usage: `{bot.BOT_MENTION} /r? @username`"), False @@ -556,7 +556,7 @@ def handle_assign_from_queue_command( issue_number: int, request: AssignmentRequest | None = None, ) -> tuple[str, bool]: - assignment_request = request or decode_assignment_request(bot, issue_number=issue_number) + assignment_request = request or build_assignment_request(bot, issue_number=issue_number) current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number) if assignee_error: return assignee_error, False From 937a652dc9a8e56ce0e0076340c00133c71a4c1a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:17:50 +0900 Subject: [PATCH 136/209] refactor(reviewer-bot): preserve command parser behavior while narrowing seams --- scripts/reviewer_bot_lib/commands.py | 24 +++++++++- tests/unit/reviewer_bot/test_commands.py | 57 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 6fdb3bc66..e1c04a993 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -16,6 +16,27 @@ ) from .guidance import get_issue_guidance, get_pr_guidance +_CONVERSATIONAL_WORDS = { + "i", + "we", + "you", + "the", + "a", + "an", + "is", + "are", + "can", + "could", + "would", + "should", + "please", + "thanks", + "thank", + "hi", + "hello", + "hey", +} + def build_assignment_request(bot, *, issue_number: int) -> AssignmentRequest: return decode_assignment_request(bot, issue_number=issue_number) @@ -60,8 +81,7 @@ def parse_command(bot, comment_body: str) -> tuple[str, list[str]] | None: malformed_match = re.search(malformed_pattern, comment_body, re.IGNORECASE | re.MULTILINE) if malformed_match: attempted = malformed_match.group(1).lower() - conversational = {"i", "we", "you", "the", "a", "an", "is", "are", "can", "could", "would", "should", "please", "thanks", "thank", "hi", "hello", "hey"} - if attempted in conversational: + if attempted in _CONVERSATIONAL_WORDS: return None if attempted in bot.COMMANDS or attempted in {"r?-user", "assign-from-queue"}: return "_malformed_known", [attempted] diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index a5e68b84b..58a3bb9d9 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -3,6 +3,7 @@ from scripts.reviewer_bot_lib import ( automation, + commands, comment_application, guidance, reconcile, @@ -335,3 +336,59 @@ def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privilege pending = review["pending_privileged_commands"]["issue_comment:100"] assert pending["status"] == "failed_closed" assert pending["result"] == "live_permission_unavailable" + + +@pytest.mark.parametrize( + ("comment_body", "expected"), + [ + ("@guidelines-bot /queue", ("queue", [])), + ("@guidelines-bot /r? producers", ("assign-from-queue", [])), + ("@guidelines-bot /r? @alice", ("r?-user", ["@alice"])), + ("@guidelines-bot queue", ("_malformed_known", ["queue"])), + ("@guidelines-bot /queue\n@guidelines-bot /pass", ("_multiple_commands", [])), + ("@guidelines-bot hello", None), + ], +) +def test_parse_command_preserves_known_command_classification(monkeypatch, comment_body, expected): + harness = CommandHarness(monkeypatch) + parser_bot = type( + "ParserBot", + (), + { + "BOT_MENTION": harness.runtime.BOT_MENTION, + "COMMANDS": { + "queue", + "pass", + "label", + "away", + "claim", + "release", + "rectify", + "sync-members", + "accept-no-fls-changes", + }, + }, + )() + + assert commands.parse_command(parser_bot, comment_body) == expected + + +def test_parse_command_preserves_quoted_args(monkeypatch): + harness = CommandHarness(monkeypatch) + + parser_bot = type("ParserBot", (), {"BOT_MENTION": harness.runtime.BOT_MENTION, "COMMANDS": {"label"}})() + + assert commands.parse_command(parser_bot, '@guidelines-bot /label +"needs decision"') == ("label", ["+needs decision"]) + + +def test_strip_code_blocks_removes_fenced_indented_and_inline_code(monkeypatch): + harness = CommandHarness(monkeypatch) + comment_body = """before +```bash +@guidelines-bot /queue +``` + @guidelines-bot /queue +inline `@guidelines-bot /queue` +after""" + + assert harness.runtime.strip_code_blocks(comment_body) == "before\n\n\ninline \nafter" From b1d725449c565f7a64b34b79d65870c8f9e2d817 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:22:18 +0900 Subject: [PATCH 137/209] refactor(reviewer-bot): remove ambient env builders from comment routing --- scripts/reviewer_bot_lib/comment_routing.py | 42 +++++++++---------- .../reviewer_bot/test_workflow_files.py | 26 ++++++------ .../unit/reviewer_bot/test_comment_routing.py | 34 +++++++++++++++ 3 files changed, 66 insertions(+), 36 deletions(-) diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index d27eacd02..08b448ef6 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -40,6 +40,14 @@ def build_pr_comment_trust_context(bot) -> PrCommentTrustContext: return decode_pr_comment_trust_context(bot) +def _resolve_comment_request(bot, request: CommentEventRequest | None, *, issue_number: int | None = None) -> CommentEventRequest: + return request or build_comment_event_request(bot, issue_number=issue_number) + + +def _resolve_trust_context(bot, trust_context: PrCommentTrustContext | None) -> PrCommentTrustContext: + return trust_context or build_pr_comment_trust_context(bot) + + def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) -> bool: if not request.is_pull_request: return True @@ -170,11 +178,11 @@ def classify_pr_comment_processing_target( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> str: - comment_request = request or build_comment_event_request(bot, issue_number=issue_number) + comment_request = _resolve_comment_request(bot, request, issue_number=issue_number) return _classify_pr_comment_processing_target( bot, comment_request, - trust_context or build_pr_comment_trust_context(bot), + _resolve_trust_context(bot, trust_context), ) @@ -201,11 +209,11 @@ def route_issue_comment_trust( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> str: - comment_request = request or build_comment_event_request(bot, issue_number=issue_number) + comment_request = _resolve_comment_request(bot, request, issue_number=issue_number) return _route_issue_comment_trust( bot, comment_request, - trust_context or build_pr_comment_trust_context(bot), + _resolve_trust_context(bot, trust_context), ) @@ -242,25 +250,13 @@ def _build_pr_comment_observer_payload( **base_payload, } body = request.comment_body - normalized = _normalize_comment_body(body) - command_pattern = re.compile(r"^@guidelines\-bot\s+/[A-Za-z0-9?_\-]+(?:\s+.*)?$") - lines = [line for line in normalized.splitlines() if line.strip()] - command_lines = [line for line in lines if command_pattern.match(line.strip())] - non_command_lines = [line for line in lines if not command_pattern.match(line.strip())] - if not normalized: - comment_class = "empty_or_whitespace" - elif command_lines and not non_command_lines: - comment_class = "command_only" - elif command_lines and non_command_lines: - comment_class = "command_plus_text" - else: - comment_class = "plain_text" + payload_classification = classify_comment_payload(bot, body) return { "schema_version": 2, **base_payload, "comment_id": comment_id, - "comment_class": comment_class, - "has_non_command_text": bool(non_command_lines), + "comment_class": payload_classification["comment_class"], + "has_non_command_text": payload_classification["has_non_command_text"], "source_body_digest": _digest_body(body), "source_created_at": request.comment_created_at, "actor_login": request.comment_author, @@ -279,11 +275,11 @@ def build_pr_comment_observer_payload( request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None, ) -> dict: - comment_request = request or build_comment_event_request(bot, issue_number=issue_number) + comment_request = _resolve_comment_request(bot, request, issue_number=issue_number) return _build_pr_comment_observer_payload( bot, comment_request, - trust_context or build_pr_comment_trust_context(bot), + _resolve_trust_context(bot, trust_context), ) @@ -304,7 +300,7 @@ def handle_comment_event( trust_context: PrCommentTrustContext | None = None, ) -> bool: bot.assert_lock_held("handle_comment_event") - comment_request = request or build_comment_event_request(bot) + comment_request = _resolve_comment_request(bot, request) issue_number = comment_request.issue_number if not issue_number: return False @@ -312,7 +308,7 @@ def handle_comment_event( route = _route_issue_comment_trust( bot, comment_request, - trust_context or build_pr_comment_trust_context(bot), + _resolve_trust_context(bot, trust_context), ) if route == "safe_noop": return False diff --git a/tests/contract/reviewer_bot/test_workflow_files.py b/tests/contract/reviewer_bot/test_workflow_files.py index 4397a9e4f..b97bf9563 100644 --- a/tests/contract/reviewer_bot/test_workflow_files.py +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -119,20 +119,20 @@ def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_obs from scripts.reviewer_bot_lib import comment_routing from tests.fixtures.comment_routing_harness import CommentRoutingHarness - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") - monkeypatch.setenv("COMMENT_USER_TYPE", "User") - monkeypatch.setenv("COMMENT_AUTHOR", "PLeVasseur") - monkeypatch.setenv("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") - monkeypatch.setenv("COMMENT_SENDER_TYPE", "User") - monkeypatch.setenv("COMMENT_INSTALLATION_ID", "") - monkeypatch.setenv("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") - monkeypatch.setenv("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") - monkeypatch.setenv("COMMENT_ID", "100") - monkeypatch.setenv("COMMENT_AUTHOR_ID", "123") - monkeypatch.setenv("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") - monkeypatch.setenv("GITHUB_RUN_ID", "999") - monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") harness = CommentRoutingHarness(monkeypatch) + harness.config.set("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + harness.config.set("COMMENT_USER_TYPE", "User") + harness.config.set("COMMENT_AUTHOR", "PLeVasseur") + harness.config.set("COMMENT_AUTHOR_ASSOCIATION", "COLLABORATOR") + harness.config.set("COMMENT_SENDER_TYPE", "User") + harness.config.set("COMMENT_INSTALLATION_ID", "") + harness.config.set("COMMENT_PERFORMED_VIA_GITHUB_APP", "false") + harness.config.set("COMMENT_BODY", "@guidelines-bot /r? @felix91gr") + harness.config.set("COMMENT_ID", "100") + harness.config.set("COMMENT_AUTHOR_ID", "123") + harness.config.set("COMMENT_CREATED_AT", "2026-03-20T20:48:25Z") + harness.config.set("GITHUB_RUN_ID", "999") + harness.config.set("GITHUB_RUN_ATTEMPT", "1") harness.github.add_api( "GET", "pulls/42", diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 7a722c1ad..56edf45fb 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -163,3 +163,37 @@ def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monke assert payload["source_run_id"] == 777 assert payload["source_run_attempt"] == 2 assert payload["pr_number"] == 42 + + +def test_build_pr_comment_observer_payload_uses_same_comment_classification_as_payload_parser(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="dana", + comment_author="alice", + comment_body="hello\n@guidelines-bot /queue", + ) + trust_context = harness.trust_context( + github_repository="rustfoundation/safety-critical-rust-coding-guidelines", + comment_author_association="MEMBER", + current_workflow_file=".github/workflows/reviewer-bot-pr-comment-trusted.yml", + github_ref="refs/heads/main", + github_run_id=777, + github_run_attempt=2, + ) + harness.add_pull_request_metadata( + issue_number=42, + head_repo_full_name="fork/example", + pr_author="dana", + ) + + payload = comment_routing.build_pr_comment_observer_payload( + harness.runtime, + 42, + request, + trust_context, + ) + + assert payload["comment_class"] == "command_plus_text" + assert payload["has_non_command_text"] is True From d57c7d8bc0b0f241671a702ff1352bdc07a70a71 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:24:24 +0900 Subject: [PATCH 138/209] refactor(reviewer-bot): use structured logging in comment routing --- scripts/reviewer_bot_lib/comment_routing.py | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index 08b448ef6..c9db6a29e 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -20,6 +20,15 @@ ) +def _log(bot, level: str, message: str, **fields) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout + print(message, file=stream) + + def _now() -> datetime: return datetime.now(timezone.utc) @@ -48,12 +57,18 @@ def _resolve_trust_context(bot, trust_context: PrCommentTrustContext | None) -> return trust_context or build_pr_comment_trust_context(bot) -def _require_v18_for_pr(state: dict, request: CommentEventRequest, context: str) -> bool: +def _require_v18_for_pr(bot, state: dict, request: CommentEventRequest, context: str) -> bool: if not request.is_pull_request: return True epoch = _runtime_epoch(state) if epoch != "freshness_v15": - print(f"V18 PR freshness path safe-noop for {context}; epoch is {epoch}") + _log( + bot, + "info", + f"V18 PR freshness path safe-noop for {context}; epoch is {epoch}", + context=context, + runtime_epoch=epoch, + ) return False return True @@ -315,11 +330,17 @@ def handle_comment_event( if route == "issue_direct": if comment_request.issue_state == "closed": removed = state.get("active_reviews", {}).pop(str(issue_number), None) - print(f"Ignoring direct comment on closed issue #{issue_number}") + _log( + bot, + "info", + f"Ignoring direct comment on closed issue #{issue_number}", + issue_number=issue_number, + issue_state=comment_request.issue_state, + ) return removed is not None return _process_comment_event(bot, state, comment_request) if route == "pr_trusted_direct": - if not _require_v18_for_pr(state, comment_request, "pr_trusted_direct_comment"): + if not _require_v18_for_pr(bot, state, comment_request, "pr_trusted_direct_comment"): return False return _process_comment_event(bot, state, comment_request) raise RuntimeError("Deferred PR comment events must not mutate directly in trusted workflows") From cc5560b916e618466566bbea01ec335acd8ce777 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:31:52 +0900 Subject: [PATCH 139/209] refactor(reviewer-bot): stabilize reconcile transport seams --- scripts/reviewer_bot_lib/reconcile.py | 35 ++++++++++++------- .../unit/reviewer_bot/test_reconcile_unit.py | 24 +++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index e563d630a..078c65c2c 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -217,6 +217,24 @@ def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: return response.payload +def _read_optional_reconcile_object(bot, endpoint: str, *, label: str) -> dict | None: + try: + return _read_reconcile_object(bot, endpoint, label=label) + except ReconcileReadError as exc: + if exc.failure_kind == "not_found": + return None + raise + + +def _read_reconcile_reviews(bot, issue_number: int) -> list[dict]: + reviews = bot.get_pull_request_reviews(issue_number) + if reviews is None: + raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") + if not isinstance(reviews, list): + raise ReconcileReadError(f"live reviews for PR #{issue_number} payload invalid", failure_kind="invalid_payload") + return reviews + + def _ensure_source_event_key(review_data: dict, source_event_key: str, payload: dict | None = None) -> None: review_data.setdefault("deferred_gaps", {}) if payload is None: @@ -249,9 +267,7 @@ def _was_reconciled_source_event(review_data: dict, source_event_key: str) -> bo def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dict) -> bool: pull_request = _read_reconcile_object(bot, f"pulls/{issue_number}", label=f"pull request #{issue_number}") - reviews = bot.get_pull_request_reviews(issue_number) - if reviews is None: - raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") + reviews = _read_reconcile_reviews(bot, issue_number) before = { "reviewer_review": deepcopy(review_data.get("reviewer_review")), "active_head_sha": review_data.get("active_head_sha"), @@ -314,8 +330,9 @@ def reconcile_active_review_entry( return "ℹ️ PR review freshness rectify is epoch-gated and currently inactive.", True, False head_repair_result = bot.maybe_record_head_observation_repair(issue_number, review_data) state_changed = head_repair_result.changed - reviews = bot.get_pull_request_reviews(issue_number) - if reviews is None: + try: + reviews = _read_reconcile_reviews(bot, issue_number) + except ReconcileReadError: return f"❌ Failed to fetch reviews for PR #{issue_number}; cannot run `/rectify`.", False, False messages: list[str] = [] refreshed, latest_review = refresh_reviewer_review_from_live_preferred_review( @@ -929,13 +946,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: expected_event_action="submitted", ) review_id = context.review_id - try: - live_review = _read_reconcile_object(bot, f"pulls/{pr_number}/reviews/{review_id}", label=f"live review #{review_id}") - except ReconcileReadError as exc: - if exc.failure_kind == "not_found": - live_review = None - else: - raise + live_review = _read_optional_reconcile_object(bot, f"pulls/{pr_number}/reviews/{review_id}", label=f"live review #{review_id}") _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number}") live_commit_id = None live_submitted_at = parsed_payload.source_submitted_at diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 36d8e1c86..3c796ea4f 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -289,3 +289,27 @@ def test_resolve_workflow_run_pr_number_fails_closed_when_pr_unavailable(monkeyp with pytest.raises(RuntimeError, match="Failed to fetch pull request #42 during workflow_run reconcile"): commands.resolve_workflow_run_pr_number(runtime) + + +def test_read_reconcile_reviews_rejects_non_list_payload(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.get_pull_request_reviews = lambda issue_number: {"unexpected": True} + + with pytest.raises(reconcile.ReconcileReadError, match="payload invalid"): + reconcile._read_reconcile_reviews(runtime, 42) + + +def test_read_optional_reconcile_object_returns_none_for_not_found(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.github_api_request = lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( + status_code=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ) + + assert reconcile._read_optional_reconcile_object(runtime, "pulls/42/reviews/11", label="live review #11") is None From 78b734b2704fa3b3c0186d6bb30161f85c5ab750 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:33:54 +0900 Subject: [PATCH 140/209] refactor(reviewer-bot): inject time and logging into reconcile --- scripts/reviewer_bot_lib/reconcile.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 078c65c2c..eff4d3659 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -24,7 +24,19 @@ ) +def _log(bot, level: str, message: str, **fields) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout + print(message, file=stream) + + def _now_iso(bot) -> str: + clock = getattr(bot, "clock", None) + if clock is not None and hasattr(clock, "now"): + return clock.now().isoformat() return bot.datetime.now(bot.timezone.utc).isoformat() @@ -888,7 +900,7 @@ def _validate_live_comment_replay_contract( def handle_workflow_run_event(bot, state: dict) -> bool: bot.assert_lock_held("handle_workflow_run_event") if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15": - print("V18 workflow_run reconcile safe-noop before epoch flip") + _log(bot, "info", "V18 workflow_run reconcile safe-noop before epoch flip") return False payload = _load_deferred_context(bot) parsed_payload = parse_deferred_context_payload(payload) @@ -905,9 +917,12 @@ def handle_workflow_run_event(bot, state: dict) -> bool: try: if isinstance(parsed_payload, ObserverNoopPayload): _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) - print( - "Observer workflow produced explicit no-op payload for " - f"{source_event_key}: {parsed_payload.reason}" + _log( + bot, + "info", + f"Observer workflow produced explicit no-op payload for {source_event_key}: {parsed_payload.reason}", + source_event_key=source_event_key, + reason=parsed_payload.reason, ) return False From 9e4f2bd22d7aebc90643f48456a5a6c99e3d57da Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:38:34 +0900 Subject: [PATCH 141/209] refactor(reviewer-bot): inject artifact download transport into sweeper --- scripts/reviewer_bot_lib/runtime.py | 16 +++++++++ scripts/reviewer_bot_lib/sweeper.py | 7 ++-- .../reviewer_bot/test_sweeper_downloads.py | 35 ++++++++++--------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 41d89f857..28999780d 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -157,6 +157,20 @@ def query( ) +class RequestsArtifactDownloadTransport: + def __init__(self, requests_module: Any): + self._requests = requests_module + + def download( + self, + url: str, + *, + headers: dict[str, str] | None = None, + timeout_seconds: float | None = None, + ) -> Any: + return self._requests.request("GET", url, headers=headers, timeout=timeout_seconds) + + class ReviewerBotRuntime: """Runtime object built from explicit services and named adapters.""" @@ -191,6 +205,7 @@ def __init__( deferred_payloads: Any | None = None, rest_transport: Any | None = None, graphql_transport: Any | None = None, + artifact_download_transport: Any | None = None, clock: Any | None = None, sleeper: Any | None = None, jitter: Any | None = None, @@ -213,6 +228,7 @@ def __init__( self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) self.rest_transport = rest_transport or RequestsRestTransport(requests) self.graphql_transport = graphql_transport or RequestsGraphQLTransport(requests) + self.artifact_download_transport = artifact_download_transport or RequestsArtifactDownloadTransport(requests) self.clock = clock or SystemClock() self.sleeper = sleeper or SystemSleeper(time) self.jitter = jitter or RandomJitterSource(random) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index b654ff41a..45e32f01a 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -12,8 +12,6 @@ from typing import Any from urllib.parse import quote -import requests - from .reconcile import ( _artifact_expected_name, _artifact_expected_payload_name, @@ -298,8 +296,7 @@ def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) response = None for attempt in range(1, max_attempts + 1): try: - response = bot.requests.request( - "GET", + response = bot.artifact_download_transport.download( download_url, headers={ "Authorization": f"Bearer {bot.get_github_token()}", @@ -307,7 +304,7 @@ def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) "X-GitHub-Api-Version": "2022-11-28", }, ) - except requests.RequestException: + except Exception: if attempt < max_attempts: time.sleep(_download_retry_delay(bot, attempt)) continue diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index 7fe3ff694..a54deb304 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -4,21 +4,30 @@ from tests.fixtures.reviewer_bot import make_zip_payload +class RecordingArtifactDownloadTransport: + def __init__(self, responses): + self._responses = iter(responses) + self.calls = [] + + def download(self, url, *, headers=None, timeout_seconds=None): + self.calls.append({"url": url, "headers": headers, "timeout_seconds": timeout_seconds}) + response = next(self._responses) + if isinstance(response, Exception): + raise response + return response + + def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): payload = {"source_event_key": "issue_comment:100"} responses = iter([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")]) success_response = FakeGitHubResponse(200, None, "") success_response.content = make_zip_payload("deferred-comment.json", payload) - def fake_request(*args, **kwargs): - return next(responses, success_response) - runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" + runtime.artifact_download_transport = RecordingArtifactDownloadTransport([*responses, success_response]) monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr(sweeper.requests, "request", fake_request) - runtime.requests = sweeper.requests status, artifact_payload = sweeper._download_artifact_payload( runtime, @@ -34,13 +43,8 @@ def test_download_artifact_payload_reports_request_exception_unavailable(monkeyp runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" + runtime.artifact_download_transport = RecordingArtifactDownloadTransport([RuntimeError("timeout")]) monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: (_ for _ in ()).throw(sweeper.requests.RequestException("timeout")), - ) - runtime.requests = sweeper.requests status, payload = sweeper._download_artifact_payload( runtime, @@ -56,13 +60,10 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - sweeper.requests, - "request", - lambda *args, **kwargs: FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), + runtime.artifact_download_transport = RecordingArtifactDownloadTransport( + [FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6 ) - runtime.requests = sweeper.requests + monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) status, payload = sweeper._download_artifact_payload( runtime, From 571fd80df7be53c002579f9f959068c5b99dce58 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:41:08 +0900 Subject: [PATCH 142/209] refactor(reviewer-bot): share retry helpers in sweeper downloads --- scripts/reviewer_bot_lib/sweeper.py | 15 +++++++++++---- tests/unit/reviewer_bot/test_sweeper_downloads.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index 45e32f01a..172766297 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -5,13 +5,13 @@ import io import json import os -import random import time import zipfile from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import quote +from . import retrying from .reconcile import ( _artifact_expected_name, _artifact_expected_payload_name, @@ -64,8 +64,15 @@ def _read_api_payload(bot, endpoint: str) -> tuple[Any | None, str | None]: def _download_retry_delay(bot, retry_attempt: int) -> float: base = float(getattr(bot, "LOCK_RETRY_BASE_SECONDS", 2.0)) - bounded_base = min(base * (2 ** max(retry_attempt - 1, 0)), 8.0) - return bounded_base + random.uniform(0, bounded_base) + + class _BotJitter: + def uniform(self, lower: float, upper: float) -> float: + jitter = getattr(bot, "jitter", None) + if jitter is not None and hasattr(jitter, "uniform"): + return jitter.uniform(lower, upper) + return __import__("random").uniform(lower, upper) + + return retrying.bounded_exponential_delay(base, retry_attempt, jitter=_BotJitter()) def observer_run_reason_from_details(run_details: dict, runbook_signature: dict | None) -> str: @@ -309,7 +316,7 @@ def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) time.sleep(_download_retry_delay(bot, attempt)) continue return "download_unavailable", None - if response.status_code == 429 or response.status_code >= 500: + if retrying.is_retryable_status(response.status_code): if attempt < max_attempts: time.sleep(_download_retry_delay(bot, attempt)) continue diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index a54deb304..b3de42751 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -1,4 +1,5 @@ from scripts.reviewer_bot_lib import sweeper +from tests.fixtures.fake_jitter import DeterministicJitter from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.http_responses import FakeGitHubResponse from tests.fixtures.reviewer_bot import make_zip_payload @@ -26,6 +27,7 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" + runtime.jitter = DeterministicJitter(0.5) runtime.artifact_download_transport = RecordingArtifactDownloadTransport([*responses, success_response]) monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) @@ -39,6 +41,15 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): assert artifact_payload == payload +def test_download_retry_delay_uses_shared_backoff_with_jitter(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.jitter = DeterministicJitter([0.25, 0.5, 0.75]) + + assert sweeper._download_retry_delay(runtime, 1) == 2.25 + assert sweeper._download_retry_delay(runtime, 2) == 4.5 + assert sweeper._download_retry_delay(runtime, 3) == 8.75 + + def test_download_artifact_payload_reports_request_exception_unavailable(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") From 98510c2413bf1ce1c6b60398896efe6ca03b6c8c Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:44:30 +0900 Subject: [PATCH 143/209] refactor(reviewer-bot): read sweeper settings from runtime config --- scripts/reviewer_bot_lib/sweeper.py | 19 +++++++++++++++---- tests/unit/reviewer_bot/test_sweeper_logic.py | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index 172766297..d5e9289cb 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -4,7 +4,6 @@ import io import json -import os import time import zipfile from datetime import datetime, timedelta, timezone @@ -38,6 +37,14 @@ def _now_iso() -> str: return _now().isoformat() +def _retention_days(bot) -> int: + return int(bot.get_config_value("DEFERRED_ARTIFACT_RETENTION_DAYS", "7") or 7) + + +def _github_repository(bot) -> str: + return bot.get_config_value("GITHUB_REPOSITORY", "") + + def _approval_pending_signature_from_runbook() -> dict | None: return None @@ -101,9 +108,8 @@ def can_mark_observer_run_missing(gap: dict, now: datetime | None = None) -> boo return bool(gap.get("full_scan_complete") and gap.get("later_recheck_complete") and not gap.get("correlated_run_found") and not gap.get("approval_pending_evidence_retained")) -def classify_artifact_gap_reason(gap: dict, now: datetime | None = None) -> str: +def classify_artifact_gap_reason(gap: dict, now: datetime | None = None, *, retention_days: int = 7) -> str: now = now or _now() - retention_days = int(os.environ.get("DEFERRED_ARTIFACT_RETENTION_DAYS", "7")) run_created_at = parse_timestamp(gap.get("run_created_at")) if gap.get("artifact_seen_at") or gap.get("artifact_last_downloadable_at"): return "artifact_expired" @@ -115,6 +121,7 @@ def classify_artifact_gap_reason(gap: dict, now: datetime | None = None) -> str: def correlate_candidate_observer_runs( + bot, source_event_key: str, *, source_event_kind: str, @@ -174,7 +181,7 @@ def correlate_candidate_observer_runs( repo = run.get("repository") if isinstance(repo, dict): full_name = repo.get("full_name") - if isinstance(full_name, str) and full_name != os.environ.get("GITHUB_REPOSITORY", ""): + if isinstance(full_name, str) and full_name != _github_repository(bot): continue prs = run.get("pull_requests") if isinstance(prs, list) and prs: @@ -838,6 +845,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: workflow_file = ".github/workflows/reviewer-bot-pr-comment-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "issue_comment") run_correlation = correlate_candidate_observer_runs( + bot, source_event_key, source_event_kind="issue_comment:created", source_event_created_at=created_at, @@ -905,6 +913,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: workflow_file = ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review") run_correlation = correlate_candidate_observer_runs( + bot, source_event_key, source_event_kind="pull_request_review:submitted", source_event_created_at=submitted_at, @@ -981,6 +990,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: workflow_file = ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review_comment") run_correlation = correlate_candidate_observer_runs( + bot, source_event_key, source_event_kind="pull_request_review_comment:created", source_event_created_at=created_at, @@ -1048,6 +1058,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: workflow_file = ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review") run_correlation = correlate_candidate_observer_runs( + bot, source_event_key, source_event_kind="pull_request_review:dismissed", source_event_created_at=dismissed_at, diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 52008826c..8ad0de591 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -312,8 +312,10 @@ def test_negative_missing_run_requires_full_scan_and_recheck(): def test_stage_a_candidate_run_correlation_is_exact_to_workflow_event_pr_and_window(monkeypatch): - monkeypatch.setenv("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") + runtime = _runtime(monkeypatch) + runtime.set_config_value("GITHUB_REPOSITORY", "rustfoundation/safety-critical-rust-coding-guidelines") result = sweeper.correlate_candidate_observer_runs( + runtime, "issue_comment:101", source_event_kind="issue_comment:created", source_event_created_at="2026-03-17T10:00:00Z", @@ -402,6 +404,19 @@ def test_artifact_gap_reason_requires_prior_visibility_or_documented_retention() assert sweeper.classify_artifact_gap_reason(missing) == "artifact_missing" +def test_artifact_gap_reason_uses_passed_retention_days(): + gap = { + "run_created_at": "2026-03-01T00:00:00Z", + "retention_window_documented": True, + } + + assert sweeper.classify_artifact_gap_reason( + gap, + now=sweeper.parse_timestamp("2026-03-05T00:00:00Z"), + retention_days=3, + ) == "artifact_expired" + + def test_sweeper_fetches_single_candidate_run_detail_without_exact_artifact_match(monkeypatch): runtime = _runtime(monkeypatch) run_correlation = {"candidate_run_ids": [123], "correlated_run": None, "correlated_run_found": False} From 0229697c0e66949e80d8dd9fb288b8327568fa5e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 22:47:22 +0900 Subject: [PATCH 144/209] refactor(reviewer-bot): add structured logging interfaces --- .../reviewer_bot/test_adapter_contract.py | 11 ++++++++++- .../reviewer_bot/test_fake_runtime_contract.py | 15 +++++++++++++++ tests/fixtures/fake_runtime.py | 2 ++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 3ab7bb1e5..7e8921a4c 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -8,7 +8,7 @@ from scripts import reviewer_bot from scripts.reviewer_bot_lib import event_inputs, lease_lock, review_state from scripts.reviewer_bot_lib.context import ReviewerBotContext -from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime, StdErrLogger from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.reviewer_bot import make_state @@ -267,3 +267,12 @@ def test_runtime_accepts_injected_infra_services(): assert runtime.logger is logger assert runtime.rest_transport is rest_transport assert runtime.graphql_transport is graphql_transport + + +def test_default_stderr_logger_renders_message_and_sorted_fields(): + writes = [] + logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) + + logger.event("warning", "retrying request", retry_attempt=2, issue_number=42) + + assert writes == ["[warning] retrying request issue_number=42 retry_attempt=2\n"] diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 8effa3ae4..01f62af34 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -22,6 +22,7 @@ def test_fake_runtime_exposes_explicit_service_fields_and_no_omnibus_service_con assert runtime.config is not None assert runtime.outputs is not None assert runtime.deferred_payloads is not None + assert runtime.logger is not None assert runtime.state_store is not None assert runtime.github is not None assert runtime.locks is not None @@ -48,6 +49,20 @@ def test_fake_runtime_output_sink_records_writes(monkeypatch): assert runtime.outputs.writes == [("state_changed", "true")] +def test_fake_runtime_recording_logger_captures_structured_events(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + runtime.logger.event("warning", "retrying", issue_number=42, retry_attempt=2) + + assert runtime.logger.records == [ + { + "level": "warning", + "message": "retrying", + "fields": {"issue_number": 42, "retry_attempt": 2}, + } + ] + + def test_fake_runtime_touched_items_preserve_uniqueness_and_drain(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index f7cfa8165..a44c693d2 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -38,6 +38,7 @@ GitHubApiResult, ) from scripts.reviewer_bot_lib.context import LeaseContext +from tests.fixtures.recording_logger import RecordingLogger class ConfigBag: @@ -210,6 +211,7 @@ def __init__(self, monkeypatch, *, github=None): self.sys = sys self.random = random self.time = time + self.logger = RecordingLogger() self.ACTIVE_LEASE_CONTEXT = LeaseContext( lock_token="test-lock-token", lock_owner_run_id="test-run", From 98896402fd085240a422fc0f62a7736f8864d20f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:19:57 +0900 Subject: [PATCH 145/209] refactor(reviewer-bot): use structured logging in github api helpers --- scripts/reviewer_bot_lib/github_api.py | 85 ++++++++++++++++------ tests/unit/reviewer_bot/test_github_api.py | 28 +++++++ 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 8e89f11fa..4416a2866 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -27,6 +27,15 @@ def uniform(self, lower: float, upper: float) -> float: return __import__("random").uniform(lower, upper) +def _log(bot: GitHubTransportContext, level: str, message: str, **fields) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = sys.stderr if level in {"warning", "error"} else sys.stdout + print(message, file=stream) + + def _should_retry_status(status_code: int | None) -> bool: return retrying.is_retryable_status(status_code) @@ -103,7 +112,7 @@ def _build_result( def get_github_token(bot: GitHubTransportContext) -> str: token = bot.get_config_value("GITHUB_TOKEN") if not token: - print("ERROR: GITHUB_TOKEN not set", file=sys.stderr) + _log(bot, "error", "GITHUB_TOKEN not set") raise SystemExit(1) return token @@ -159,7 +168,7 @@ def github_api_request( time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: - print(f"GitHub API transport error: {exc}", file=sys.stderr) + _log(bot, "error", f"GitHub API transport error: {exc}", transport_error=str(exc)) return _build_result( bot, status_code=None, @@ -201,7 +210,13 @@ def github_api_request( continue if not suppress_error_log: - print(f"GitHub API error: {response.status_code} - {response.text}", file=sys.stderr) + _log( + bot, + "error", + f"GitHub API error: {response.status_code} - {response.text}", + status_code=response.status_code, + failure_kind=failure_kind, + ) return _build_result( bot, status_code=response.status_code, @@ -261,7 +276,7 @@ def github_graphql_request( time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: - print(f"GitHub GraphQL transport error: {exc}", file=sys.stderr) + _log(bot, "error", f"GitHub GraphQL transport error: {exc}", transport_error=str(exc)) return _build_result( bot, status_code=None, @@ -310,7 +325,13 @@ def github_graphql_request( details = response.text if graphql_errors: details = json.dumps(graphql_errors, sort_keys=True) - print(f"GitHub GraphQL error: {response.status_code} - {details}", file=sys.stderr) + _log( + bot, + "error", + f"GitHub GraphQL error: {response.status_code} - {details}", + status_code=response.status_code, + failure_kind=failure_kind, + ) return _build_result( bot, status_code=response.status_code, @@ -377,10 +398,13 @@ def add_label_with_status(bot: GitHubTransportContext, issue_number: int, label: raise RuntimeError( f"Permission denied adding label '{label}' to #{issue_number}: {response.text}" ) - print( - f"WARNING: Failed to add label '{label}' to #{issue_number} " - f"(status {response.status_code}): {response.text}", - file=sys.stderr, + _log( + bot, + "warning", + f"Failed to add label '{label}' to #{issue_number} (status {response.status_code}): {response.text}", + issue_number=issue_number, + label=label, + status_code=response.status_code, ) return False @@ -397,10 +421,13 @@ def remove_label_with_status(bot: GitHubTransportContext, issue_number: int, lab raise RuntimeError( f"Permission denied removing label '{label}' from #{issue_number}: {response.text}" ) - print( - f"WARNING: Failed to remove label '{label}' from #{issue_number} " - f"(status {response.status_code}): {response.text}", - file=sys.stderr, + _log( + bot, + "warning", + f"Failed to remove label '{label}' from #{issue_number} (status {response.status_code}): {response.text}", + issue_number=issue_number, + label=label, + status_code=response.status_code, ) return False @@ -427,10 +454,12 @@ def ensure_label_exists( if response.status_code in {201, 422}: return True - print( - f"WARNING: Failed to ensure label '{label}' exists (status {response.status_code}): " - f"{response.text}", - file=sys.stderr, + _log( + bot, + "warning", + f"Failed to ensure label '{label}' exists (status {response.status_code}): {response.text}", + label=label, + status_code=response.status_code, ) return False @@ -463,9 +492,14 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, if response.status_code == 429 or response.status_code >= 500: if attempt < lock_api_retry_limit: delay = lock_retry_base_seconds + random.uniform(0, lock_retry_base_seconds) - print( - f"Retryable {assignment_target} API failure for @{username} on #{issue_number} " - f"(status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})" + _log( + bot, + "warning", + f"Retryable {assignment_target} API failure for @{username} on #{issue_number} (status {response.status_code}); retrying ({attempt}/{lock_api_retry_limit})", + issue_number=issue_number, + username=username, + status_code=response.status_code, + retry_attempt=attempt, ) time.sleep(delay) continue @@ -475,10 +509,13 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, exhausted_retryable_failure=True, ) - print( - f"WARNING: Unexpected {assignment_target} API status {response.status_code} " - f"for @{username} on #{issue_number}: {response.text}", - file=sys.stderr, + _log( + bot, + "warning", + f"Unexpected {assignment_target} API status {response.status_code} for @{username} on #{issue_number}: {response.text}", + issue_number=issue_number, + username=username, + status_code=response.status_code, ) return bot.AssignmentAttempt(success=False, status_code=response.status_code) diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 0aa6fb78c..e6358d36f 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -5,6 +5,7 @@ from scripts.reviewer_bot_lib import automation, github_api from scripts.reviewer_bot_lib.config import LOCK_API_RETRY_LIMIT, GitHubApiResult from tests.fixtures.http_responses import FakeGitHubResponse +from tests.fixtures.recording_logger import RecordingLogger class RecordingRestTransport: @@ -57,6 +58,7 @@ def _bot(**overrides): get_config_value=lambda name, default="": __import__("os").environ.get(name, default), rest_transport=RecordingRestTransport(), graphql_transport=RecordingGraphQLTransport(), + logger=RecordingLogger(), STATE_ISSUE_NUMBER=1, ) for key, value in overrides.items(): @@ -289,6 +291,32 @@ def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): assert "timeout" in str(result.transport_error) +def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + transport = RecordingRestTransport([github_api.requests.RequestException("timeout")]) + bot = _bot(rest_transport=transport) + + result = github_api.github_api_request(bot, "GET", "issues/42", suppress_error_log=False) + + assert result.ok is False + assert bot.logger.records[-1]["level"] == "error" + assert "transport error" in bot.logger.records[-1]["message"] + + +def test_github_api_request_logs_error_response_to_recording_logger(monkeypatch): + monkeypatch.setenv("REPO_OWNER", "rustfoundation") + monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") + transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) + bot = _bot(rest_transport=transport) + + result = github_api.github_api_request(bot, "GET", "issues/42", suppress_error_log=False) + + assert result.ok is False + assert bot.logger.records[-1]["level"] == "error" + assert bot.logger.records[-1]["fields"]["status_code"] == 403 + + def test_github_graphql_request_passes_timeout(monkeypatch): transport = RecordingGraphQLTransport([FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")]) From ef11f92a33c6bbc0cbdbe58d801443a60f22aeab Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:22:34 +0900 Subject: [PATCH 146/209] refactor(reviewer-bot): use structured logging in state and lock services --- scripts/reviewer_bot_lib/lease_lock.py | 5 +++-- scripts/reviewer_bot_lib/state_store.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index 488c02a52..e20413583 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -1,6 +1,7 @@ """Reviewer-bot lease lock helpers.""" import json +import sys from datetime import datetime, timezone from typing import Any @@ -24,8 +25,8 @@ def _log(bot: LeaseLockContext, level: str, message: str, **fields: Any) -> None if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) return - stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout - print(message, file=stream) + stream = sys.stderr if level in {"warning", "error"} else sys.stdout + stream.write(f"{message}\n") def _sleep(bot: LeaseLockContext, seconds: float) -> None: diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 57306b45e..0de7b2074 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -2,6 +2,7 @@ import json import re +import sys from datetime import datetime, timezone from typing import Any @@ -33,11 +34,12 @@ def _log(bot: StateStoreContext, level: str, message: str, **fields: Any) -> Non if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) return - print(message, file=__import__("sys").stderr) + sys.stderr.write(f"{message}\n") def _log_fallback(level: str, message: str) -> None: - print(message, file=__import__("sys").stderr) + del level + sys.stderr.write(f"{message}\n") def _sleep(bot: StateStoreContext, seconds: float) -> None: From e3cbe5a5552e80a0809de82d7ec24123a235b584 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:25:27 +0900 Subject: [PATCH 147/209] refactor(reviewer-bot): use structured logging in orchestration paths --- scripts/reviewer_bot_lib/app.py | 56 +++++++++++++++++++----------- scripts/reviewer_bot_lib/events.py | 17 ++++++--- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 53de269a2..508cd1aee 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -15,6 +15,15 @@ ) +def _log(bot: AppExecutionRuntime, level: str, message: str, **fields) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = sys.stderr if level in {"warning", "error"} else sys.stdout + stream.write(f"{message}\n") + + def _revalidate_epoch(bot: AppExecutionRuntime, expected_epoch: str | None, phase: str) -> None: if expected_epoch is None: return @@ -145,9 +154,14 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes event_action = context.event_action event_intent = _classify_event_intent_from_context(bot, context) lock_required = event_intent == bot.EVENT_INTENT_MUTATING - print( - f"Event: {event_name}, Action: {event_action}, Intent: {event_intent}, " - f"Lock Required: {lock_required}" + _log( + bot, + "info", + f"Event: {event_name}, Action: {event_action}, Intent: {event_intent}, Lock Required: {lock_required}", + event_name=event_name, + event_action=event_action, + event_intent=event_intent, + lock_required=lock_required, ) lock_acquired = False @@ -178,11 +192,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes if lock_required: state, restored = bot.process_pass_until_expirations(state) if restored: - print(f"Restored from pass-until: {restored}") + _log(bot, "info", f"Restored from pass-until: {restored}", restored=restored) state, sync_changes = bot.sync_members_with_queue(state) if sync_changes: - print(f"Members sync changes: {sync_changes}") + _log(bot, "info", f"Members sync changes: {sync_changes}", sync_changes=sync_changes) if event_name == "issues": if event_action == "opened": @@ -223,9 +237,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes if context.workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: state_changed = bot.handle_workflow_run_event(state) else: - print( - "Ignoring workflow_run event with unsupported source event: " - f"{context.workflow_run_event or ''}" + _log( + bot, + "info", + f"Ignoring workflow_run event with unsupported source event: {context.workflow_run_event or ''}", + workflow_run_event=context.workflow_run_event or "", ) touched_items = bot.drain_touched_items() @@ -264,7 +280,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes "to 0. Set ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE=true to override." ) - print("State updates detected; attempting to persist reviewer-bot state.") + _log(bot, "info", "State updates detected; attempting to persist reviewer-bot state.") _revalidate_epoch(bot, loaded_epoch, "authoritative save") if not bot.save_state(state): raise RuntimeError( @@ -286,9 +302,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes status_labels_changed = bot.sync_status_labels_for_items(state, touched_items) except RuntimeError as exc: projection_failure = exc - print( - f"WARNING: Authoritative state is persisted but status-label projection failed: {exc}", - file=sys.stderr, + _log( + bot, + "warning", + f"Authoritative state is persisted but status-label projection failed: {exc}", + projection_error=str(exc), ) if _mark_projection_repair_needed(bot, state, touched_items, str(exc)): _revalidate_epoch(bot, loaded_epoch, "projection-failure repair marker save") @@ -312,16 +330,17 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes "true" if execution_state_changed else "false", ) if projection_failure is not None: - print( + _log( + bot, + "warning", "PROJECTION_REPAIR_REQUIRED: labels remain unchanged until a trusted repair path succeeds.", - file=sys.stderr, ) except RuntimeError as exc: - print(f"ERROR: {exc}", file=sys.stderr) + _log(bot, "error", f"ERROR: {exc}", error=str(exc)) exit_code = 1 except Exception as exc: # pragma: no cover - defensive hard-fail path - print(f"ERROR: Unexpected reviewer-bot failure: {exc}", file=sys.stderr) + _log(bot, "error", f"ERROR: Unexpected reviewer-bot failure: {exc}", error=str(exc)) exit_code = 1 finally: if lock_acquired: @@ -329,10 +348,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes release_failed = True if release_failed: - print( - "ERROR: Failed to release reviewer-bot lease lock after processing event.", - file=sys.stderr, - ) + _log(bot, "error", "ERROR: Failed to release reviewer-bot lease lock after processing event.") exit_code = 1 return ExecutionResult( diff --git a/scripts/reviewer_bot_lib/events.py b/scripts/reviewer_bot_lib/events.py index 40889164c..3474f0c3c 100644 --- a/scripts/reviewer_bot_lib/events.py +++ b/scripts/reviewer_bot_lib/events.py @@ -3,6 +3,15 @@ from __future__ import annotations +def _log(bot, level: str, message: str, **fields) -> None: + logger = getattr(bot, "logger", None) + if logger is not None and hasattr(logger, "event"): + logger.event(level, message, **fields) + return + stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout + stream.write(f"{message}\n") + + def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" @@ -16,7 +25,7 @@ def _require_v18_for_pr(bot, state: dict, context: str) -> bool: return True epoch = _runtime_epoch(state) if epoch != "freshness_v15": - print(f"V18 PR freshness path safe-noop for {context}; epoch is {epoch}") + _log(bot, "info", f"V18 PR freshness path safe-noop for {context}; epoch is {epoch}", context=context, runtime_epoch=epoch) return False return True @@ -26,7 +35,7 @@ def _require_legacy_for_legacy_pr(bot, state: dict, context: str) -> bool: return True epoch = _runtime_epoch(state) if epoch == "freshness_v15": - print(f"Legacy PR freshness path safe-noop for {context}; epoch is {epoch}") + _log(bot, "info", f"Legacy PR freshness path safe-noop for {context}; epoch is {epoch}", context=context, runtime_epoch=epoch) return False return True @@ -37,10 +46,10 @@ def handle_pull_request_review_event(bot, state: dict) -> bool: return False bot.collect_touched_item(issue_number) if _runtime_epoch(state) == "freshness_v15": - print("Legacy direct pull_request_review mutation disabled after epoch flip") + _log(bot, "info", "Legacy direct pull_request_review mutation disabled after epoch flip", issue_number=issue_number) return False review_action = bot.get_config_value("EVENT_ACTION", "").strip().lower() if review_action not in {"submitted", "dismissed"}: return False - print(f"Deferring pull_request_review {review_action} for #{issue_number}") + _log(bot, "info", f"Deferring pull_request_review {review_action} for #{issue_number}", issue_number=issue_number, review_action=review_action) return False From fcb0f9aabe6368820b445b1a6abb770d10c9aaa6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:30:02 +0900 Subject: [PATCH 148/209] refactor(reviewer-bot): narrow leaf module protocol dependencies --- scripts/reviewer_bot_lib/context.py | 42 +++++++++++++++++++ scripts/reviewer_bot_lib/event_inputs.py | 24 +++++------ scripts/reviewer_bot_lib/events.py | 12 +++--- scripts/reviewer_bot_lib/project_board.py | 24 ++++++++--- .../reviewer_bot/test_runtime_protocols.py | 14 ++++++- 5 files changed, 92 insertions(+), 24 deletions(-) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index ceda205fc..58b28e3c4 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -202,6 +202,48 @@ def download( ) -> Any: ... +@runtime_checkable +class EventInputsContext(Protocol): + def get_config_value(self, name: str, default: str = "") -> str: ... + + +@runtime_checkable +class EventHandlerContext(Protocol): + logger: Logger + + def get_config_value(self, name: str, default: str = "") -> str: ... + + def collect_touched_item(self, issue_number: int | None) -> None: ... + + +@runtime_checkable +class ProjectBoardMetadataContext(Protocol): + def get_config_value(self, name: str, default: str = "") -> str: ... + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: ... + + def github_graphql( + self, + query: str, + variables: dict | None = None, + *, + token: str | None = None, + ) -> Any | None: ... + + +@runtime_checkable +class ProjectBoardProjectionContext(Protocol): + def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: ... + + def compute_reviewer_response_state( + self, + issue_number: int, + review_data: dict, + *, + issue_snapshot: dict | None = None, + ) -> dict: ... + + @runtime_checkable class AppEventContextRuntime(Protocol): EVENT_INTENT_MUTATING: str diff --git a/scripts/reviewer_bot_lib/event_inputs.py b/scripts/reviewer_bot_lib/event_inputs.py index cb02aa37d..0d9c00dbd 100644 --- a/scripts/reviewer_bot_lib/event_inputs.py +++ b/scripts/reviewer_bot_lib/event_inputs.py @@ -6,10 +6,10 @@ from pathlib import Path from .context import ( - AppEventContextRuntime, AssignmentRequest, CommentEventRequest, EventContext, + EventInputsContext, IssueLifecycleRequest, LabelEventRequest, ManualDispatchRequest, @@ -53,18 +53,18 @@ def _parse_labels(value: str) -> tuple[str, ...]: return tuple(str(label) for label in payload) -def parse_issue_labels(bot: AppEventContextRuntime) -> list[str]: +def parse_issue_labels(bot: EventInputsContext) -> list[str]: return list(_parse_labels(bot.get_config_value("ISSUE_LABELS", "[]"))) -def get_target_repo_root(bot: AppEventContextRuntime) -> Path | None: +def get_target_repo_root(bot: EventInputsContext) -> Path | None: configured = bot.get_config_value("REVIEWER_BOT_TARGET_REPO_ROOT", "").strip() if not configured: return None return Path(configured) -def build_event_context(bot: AppEventContextRuntime) -> EventContext: +def build_event_context(bot: EventInputsContext) -> EventContext: return EventContext( event_name=bot.get_config_value("EVENT_NAME").strip(), event_action=bot.get_config_value("EVENT_ACTION").strip(), @@ -92,7 +92,7 @@ def build_event_context(bot: AppEventContextRuntime) -> EventContext: ) -def build_comment_event_request(bot: AppEventContextRuntime, *, issue_number: int | None = None) -> CommentEventRequest: +def build_comment_event_request(bot: EventInputsContext, *, issue_number: int | None = None) -> CommentEventRequest: return CommentEventRequest( issue_number=issue_number if issue_number is not None else (_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0), is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), @@ -111,7 +111,7 @@ def build_comment_event_request(bot: AppEventContextRuntime, *, issue_number: in ) -def build_pr_comment_trust_context(bot: AppEventContextRuntime) -> PrCommentTrustContext: +def build_pr_comment_trust_context(bot: EventInputsContext) -> PrCommentTrustContext: return PrCommentTrustContext( github_repository=bot.get_config_value("GITHUB_REPOSITORY"), comment_author_association=bot.get_config_value("COMMENT_AUTHOR_ASSOCIATION").strip(), @@ -122,7 +122,7 @@ def build_pr_comment_trust_context(bot: AppEventContextRuntime) -> PrCommentTrus ) -def build_assignment_request(bot: AppEventContextRuntime, *, issue_number: int) -> AssignmentRequest: +def build_assignment_request(bot: EventInputsContext, *, issue_number: int) -> AssignmentRequest: return AssignmentRequest( issue_number=issue_number, issue_author=bot.get_config_value("ISSUE_AUTHOR"), @@ -134,7 +134,7 @@ def build_assignment_request(bot: AppEventContextRuntime, *, issue_number: int) def build_privileged_command_request( - bot: AppEventContextRuntime, + bot: EventInputsContext, *, issue_number: int, actor: str = "", @@ -155,7 +155,7 @@ def build_privileged_command_request( ) -def build_manual_dispatch_request(bot: AppEventContextRuntime) -> ManualDispatchRequest: +def build_manual_dispatch_request(bot: EventInputsContext) -> ManualDispatchRequest: return ManualDispatchRequest( action=bot.get_config_value("MANUAL_ACTION").strip(), issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")), @@ -163,7 +163,7 @@ def build_manual_dispatch_request(bot: AppEventContextRuntime) -> ManualDispatch ) -def build_issue_lifecycle_request(bot: AppEventContextRuntime) -> IssueLifecycleRequest: +def build_issue_lifecycle_request(bot: EventInputsContext) -> IssueLifecycleRequest: return IssueLifecycleRequest( issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), @@ -180,7 +180,7 @@ def build_issue_lifecycle_request(bot: AppEventContextRuntime) -> IssueLifecycle ) -def build_label_event_request(bot: AppEventContextRuntime) -> LabelEventRequest: +def build_label_event_request(bot: EventInputsContext) -> LabelEventRequest: return LabelEventRequest( issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, is_pull_request=bool(_parse_optional_bool(bot.get_config_value("IS_PULL_REQUEST"))), @@ -188,7 +188,7 @@ def build_label_event_request(bot: AppEventContextRuntime) -> LabelEventRequest: ) -def build_pull_request_sync_request(bot: AppEventContextRuntime) -> PullRequestSyncRequest: +def build_pull_request_sync_request(bot: EventInputsContext) -> PullRequestSyncRequest: return PullRequestSyncRequest( issue_number=_parse_optional_int(bot.get_config_value("ISSUE_NUMBER")) or 0, head_sha=bot.get_config_value("PR_HEAD_SHA").strip(), diff --git a/scripts/reviewer_bot_lib/events.py b/scripts/reviewer_bot_lib/events.py index 3474f0c3c..8e9be0eba 100644 --- a/scripts/reviewer_bot_lib/events.py +++ b/scripts/reviewer_bot_lib/events.py @@ -2,8 +2,10 @@ from __future__ import annotations +from .context import EventHandlerContext -def _log(bot, level: str, message: str, **fields) -> None: + +def _log(bot: EventHandlerContext, level: str, message: str, **fields) -> None: logger = getattr(bot, "logger", None) if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) @@ -16,11 +18,11 @@ def _runtime_epoch(state: dict) -> str: return str(state.get("freshness_runtime_epoch", "")).strip() or "legacy_v14" -def _is_pr_event(bot) -> bool: +def _is_pr_event(bot: EventHandlerContext) -> bool: return bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true" -def _require_v18_for_pr(bot, state: dict, context: str) -> bool: +def _require_v18_for_pr(bot: EventHandlerContext, state: dict, context: str) -> bool: if not _is_pr_event(bot): return True epoch = _runtime_epoch(state) @@ -30,7 +32,7 @@ def _require_v18_for_pr(bot, state: dict, context: str) -> bool: return True -def _require_legacy_for_legacy_pr(bot, state: dict, context: str) -> bool: +def _require_legacy_for_legacy_pr(bot: EventHandlerContext, state: dict, context: str) -> bool: if not _is_pr_event(bot): return True epoch = _runtime_epoch(state) @@ -40,7 +42,7 @@ def _require_legacy_for_legacy_pr(bot, state: dict, context: str) -> bool: return True -def handle_pull_request_review_event(bot, state: dict) -> bool: +def handle_pull_request_review_event(bot: EventHandlerContext, state: dict) -> bool: issue_number = int(bot.get_config_value("ISSUE_NUMBER", "0") or 0) if not issue_number: return False diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index c97866d2d..bf8fe0a7f 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -27,6 +27,7 @@ REVIEWER_BOARD_PROJECT_MANIFEST, REVIEWER_BOARD_PROJECT_NUMBER, ) +from .context import ProjectBoardMetadataContext, ProjectBoardProjectionContext PROJECT_BOARD_METADATA_QUERY = """ query ReviewerBoardProjectMetadata($organization: String!, $projectNumber: Int!) { @@ -119,7 +120,7 @@ class BoardPreviewResult: noop_reason: str | None -def reviewer_board_enabled(bot) -> bool: +def reviewer_board_enabled(bot: ProjectBoardMetadataContext) -> bool: return bot.get_config_value(REVIEWER_BOARD_ENABLED_ENV, "false").strip().lower() == "true" @@ -136,7 +137,7 @@ def _field_type_name(field_node: dict[str, Any]) -> str: return typename.lower() -def resolve_project_metadata(bot) -> ProjectMetadata: +def resolve_project_metadata(bot: ProjectBoardMetadataContext) -> ProjectMetadata: cached = getattr(bot, "_reviewer_board_project_metadata", None) if isinstance(cached, ProjectMetadata): return cached @@ -190,7 +191,7 @@ def resolve_project_metadata(bot) -> ProjectMetadata: return metadata -def validate_project_manifest(bot, metadata: ProjectMetadata) -> tuple[str, ...]: +def validate_project_manifest(bot: ProjectBoardMetadataContext, metadata: ProjectMetadata) -> tuple[str, ...]: del bot errors: list[str] = [] for field_name, expected in REVIEWER_BOARD_PROJECT_MANIFEST.items(): @@ -208,7 +209,7 @@ def validate_project_manifest(bot, metadata: ProjectMetadata) -> tuple[str, ...] return tuple(errors) -def reviewer_board_preflight(bot) -> ProjectBoardPreflight: +def reviewer_board_preflight(bot: ProjectBoardMetadataContext) -> ProjectBoardPreflight: if not reviewer_board_enabled(bot): return ProjectBoardPreflight( enabled=False, @@ -257,7 +258,12 @@ def _format_date(value: Any) -> str | None: return value[:10] -def _derive_review_state(bot, issue_number: int, review_data_snapshot: dict[str, Any], issue_snapshot: dict[str, Any]) -> ReviewStateDerivation: +def _derive_review_state( + bot: ProjectBoardProjectionContext, + issue_number: int, + review_data_snapshot: dict[str, Any], + issue_snapshot: dict[str, Any], +) -> ReviewStateDerivation: preview_review_data = copy.deepcopy(review_data_snapshot) derived = bot.compute_reviewer_response_state( issue_number, @@ -272,7 +278,13 @@ def _derive_review_state(bot, issue_number: int, review_data_snapshot: dict[str, ) -def build_board_projection_input(bot, state: dict, issue_number: int, *, issue_snapshot: dict[str, Any] | None = None) -> BoardProjectionInput: +def build_board_projection_input( + bot: ProjectBoardProjectionContext, + state: dict, + issue_number: int, + *, + issue_snapshot: dict[str, Any] | None = None, +) -> BoardProjectionInput: if issue_snapshot is None: issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): diff --git a/tests/contract/reviewer_bot/test_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py index ea2c856dd..9d8a7ca05 100644 --- a/tests/contract/reviewer_bot/test_runtime_protocols.py +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -3,7 +3,14 @@ pytestmark = pytest.mark.contract from scripts import reviewer_bot -from scripts.reviewer_bot_lib.context import AppEventContextRuntime, AppExecutionRuntime +from scripts.reviewer_bot_lib.context import ( + AppEventContextRuntime, + AppExecutionRuntime, + EventHandlerContext, + EventInputsContext, + ProjectBoardMetadataContext, + ProjectBoardProjectionContext, +) from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime from tests.fixtures.fake_runtime import FakeReviewerBotRuntime @@ -20,6 +27,10 @@ def test_runtime_object_satisfies_runtime_context_protocols(): assert isinstance(runtime, AppEventContextRuntime) assert isinstance(runtime, AppExecutionRuntime) + assert isinstance(runtime, EventInputsContext) + assert isinstance(runtime, EventHandlerContext) + assert isinstance(runtime, ProjectBoardMetadataContext) + assert isinstance(runtime, ProjectBoardProjectionContext) def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): @@ -27,3 +38,4 @@ def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): assert isinstance(runtime, AppEventContextRuntime) assert isinstance(runtime, AppExecutionRuntime) + assert isinstance(runtime, EventInputsContext) From da622582fe9f1d6c0f8b6eedab7a26f59841950b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:38:18 +0900 Subject: [PATCH 149/209] refactor(reviewer-bot): narrow core module protocol dependencies --- scripts/reviewer_bot_lib/context.py | 109 +++++++++++++++--- scripts/reviewer_bot_lib/github_api.py | 12 +- scripts/reviewer_bot_lib/lease_lock.py | 26 ++--- scripts/reviewer_bot_lib/state_store.py | 18 +-- scripts/reviewer_bot_lib/sweeper.py | 21 ++-- .../reviewer_bot/test_runtime_protocols.py | 21 ++++ tests/fixtures/fake_runtime.py | 86 ++++++++++++++ 7 files changed, 242 insertions(+), 51 deletions(-) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 58b28e3c4..179a5ce32 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -281,17 +281,27 @@ def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int] @runtime_checkable -class GitHubTransportContext(Protocol): - """GitHub API transport and mutation surface used by low-level helpers.""" +class GitHubApiContext(Protocol): + """Minimal runtime surface required by github_api helpers.""" LOCK_API_RETRY_LIMIT: int LOCK_RETRY_BASE_SECONDS: float REVIEWER_REQUEST_422_TEMPLATE: str AssignmentAttempt: type[AssignmentAttempt] GitHubApiResult: type[GitHubApiResult] + logger: Logger + rest_transport: RestTransport + graphql_transport: GraphQLTransport + def get_config_value(self, name: str, default: str = "") -> str: ... def get_github_token(self) -> str: ... def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: ... + + +@runtime_checkable +class GitHubTransportContext(GitHubApiContext, Protocol): + """Compatibility transport surface expected by call sites.""" + def github_api_request( self, method: str, @@ -328,8 +338,8 @@ def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: ... @runtime_checkable -class StateStoreContext(Protocol): - """State issue and serialization surface used by state-store helpers.""" +class StateStoreRuntimeContext(Protocol): + """Minimal runtime surface required by state_store helpers.""" ACTIVE_LEASE_CONTEXT: LeaseContext | None STATE_ISSUE_NUMBER: int @@ -338,8 +348,12 @@ class StateStoreContext(Protocol): LOCK_API_RETRY_LIMIT: int LOCK_RETRY_BASE_SECONDS: float GitHubApiResult: type[GitHubApiResult] - sys: Any + logger: Logger + sleeper: Sleeper + jitter: JitterSource + clock: Clock + def get_config_value(self, name: str, default: str = "") -> str: ... def github_api_request( self, method: str, @@ -351,9 +365,6 @@ def github_api_request( timeout_seconds: float | None = None, suppress_error_log: bool = False, ) -> GitHubApiResult: ... - def get_state_issue(self) -> dict | None: ... - def get_state_issue_snapshot(self) -> StateIssueSnapshot | None: ... - def conditional_patch_state_issue(self, body: str, etag: str | None = None) -> GitHubApiResult: ... def parse_lock_metadata_from_issue_body(self, body: str) -> dict: ... def render_state_issue_body( self, @@ -367,11 +378,23 @@ def assert_lock_held(self, operation: str) -> None: ... def parse_iso8601_timestamp(self, value: Any) -> datetime | None: ... def normalize_lock_metadata(self, lock_meta: dict | None) -> dict: ... def ensure_state_issue_lease_lock_fresh(self) -> bool: ... + def state_issue_number(self) -> int: ... + def lock_api_retry_limit(self) -> int: ... + def lock_retry_base_seconds(self) -> float: ... + + +@runtime_checkable +class StateStoreContext(StateStoreRuntimeContext, Protocol): + """Compatibility state-store surface expected by current runtime.""" + + def get_state_issue(self) -> dict | None: ... + def get_state_issue_snapshot(self) -> StateIssueSnapshot | None: ... + def conditional_patch_state_issue(self, body: str, etag: str | None = None) -> GitHubApiResult: ... @runtime_checkable -class LeaseLockContext(Protocol): - """Lock-specific runtime surface used by lease-lock helpers.""" +class LeaseLockRuntimeContext(Protocol): + """Minimal runtime surface required by lease_lock helpers.""" ACTIVE_LEASE_CONTEXT: LeaseContext | None LOCK_API_RETRY_LIMIT: int @@ -384,8 +407,14 @@ class LeaseLockContext(Protocol): LOCK_COMMIT_MARKER: str LOCK_SCHEMA_VERSION: int LeaseContext: type[LeaseContext] - sys: Any + logger: Logger + sleeper: Sleeper + jitter: JitterSource + clock: Clock + uuid_source: UuidSource + time: Any + def get_config_value(self, name: str, default: str = "") -> str: ... def parse_iso8601_timestamp(self, value: Any) -> datetime | None: ... def normalize_lock_metadata(self, lock_meta: dict | None) -> dict: ... def clear_lock_metadata(self) -> dict: ... @@ -403,7 +432,6 @@ def github_api_request( ) -> GitHubApiResult: ... def get_lock_ref_display(self) -> str: ... def get_state_issue_html_url(self) -> str: ... - def get_lock_ref_snapshot(self) -> tuple[str, str, dict]: ... def build_lock_metadata( self, lock_token: str, @@ -411,12 +439,67 @@ def build_lock_metadata( lock_owner_workflow: str, lock_owner_job: str, ) -> dict: ... + def lock_is_currently_valid(self, lock_meta: dict, now: datetime | None = None) -> bool: ... + def lock_api_retry_limit(self) -> int: ... + def lock_retry_base_seconds(self) -> float: ... + def lock_lease_ttl_seconds(self) -> int: ... + def lock_max_wait_seconds(self) -> int: ... + def lock_renewal_window_seconds(self) -> int: ... + def lock_ref_name(self) -> str: ... + def lock_ref_bootstrap_branch(self) -> str: ... + + +@runtime_checkable +class LeaseLockContext(LeaseLockRuntimeContext, Protocol): + """Compatibility lock surface expected by current runtime.""" + + def get_lock_ref_snapshot(self) -> tuple[str, str, dict]: ... def create_lock_commit(self, parent_sha: str, tree_sha: str, lock_meta: dict) -> GitHubApiResult: ... def cas_update_lock_ref(self, new_sha: str) -> GitHubApiResult: ... - def lock_is_currently_valid(self, lock_meta: dict, now: datetime | None = None) -> bool: ... def renew_state_issue_lease_lock(self, context: LeaseContext) -> bool: ... +@runtime_checkable +class SweeperContext(Protocol): + LOCK_RETRY_BASE_SECONDS: float + STATUS_PROJECTION_EPOCH: str + TRANSITION_PERIOD_DAYS: int + DEFERRED_DISCOVERY_OVERLAP_SECONDS: int + DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS: int + REVIEW_FRESHNESS_RUNBOOK_PATH: str + GitHubApiResult: type[GitHubApiResult] + artifact_download_transport: ArtifactDownloadTransport + jitter: JitterSource + logger: Logger + + def get_config_value(self, name: str, default: str = "") -> str: ... + def get_github_token(self) -> str: ... + def github_api_request( + self, + method: str, + endpoint: str, + data: dict | None = None, + extra_headers: dict[str, str] | None = None, + *, + retry_policy: str = "none", + timeout_seconds: float | None = None, + suppress_error_log: bool = False, + ) -> GitHubApiResult: ... + def github_api(self, method: str, endpoint: str, data: dict | None = None) -> Any | None: ... + def get_pull_request_reviews(self, issue_number: int) -> list[dict] | None: ... + def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: ... + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict) -> HeadObservationRepairResult: ... + def compute_reviewer_response_state( + self, + issue_number: int, + review_data: dict, + *, + issue_snapshot: dict | None = None, + pull_request: dict | None = None, + reviews: list[dict] | None = None, + ) -> dict[str, object]: ... + + @runtime_checkable class ReviewerBotContext(GitHubTransportContext, StateStoreContext, LeaseLockContext, Protocol): """Broader runtime surface expected by orchestration-heavy extracted modules. diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 4416a2866..dd7c6fd93 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -16,7 +16,7 @@ REVIEWER_BOARD_TOKEN_ENV, STATUS_LABEL_CONFIG, ) -from .context import GitHubTransportContext +from .context import GitHubApiContext, GitHubTransportContext RETRY_POLICY_NONE = retrying.RETRY_POLICY_NONE RETRY_POLICY_IDEMPOTENT_READ = retrying.RETRY_POLICY_IDEMPOTENT_READ @@ -27,7 +27,7 @@ def uniform(self, lower: float, upper: float) -> float: return __import__("random").uniform(lower, upper) -def _log(bot: GitHubTransportContext, level: str, message: str, **fields) -> None: +def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: logger = getattr(bot, "logger", None) if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) @@ -109,7 +109,7 @@ def _build_result( ) -def get_github_token(bot: GitHubTransportContext) -> str: +def get_github_token(bot: GitHubApiContext) -> str: token = bot.get_config_value("GITHUB_TOKEN") if not token: _log(bot, "error", "GITHUB_TOKEN not set") @@ -117,7 +117,7 @@ def get_github_token(bot: GitHubTransportContext) -> str: return token -def get_github_graphql_token(bot: GitHubTransportContext, *, prefer_board_token: bool = False) -> str: +def get_github_graphql_token(bot: GitHubApiContext, *, prefer_board_token: bool = False) -> str: if prefer_board_token: token = bot.get_config_value(REVIEWER_BOARD_TOKEN_ENV) if not token: @@ -127,7 +127,7 @@ def get_github_graphql_token(bot: GitHubTransportContext, *, prefer_board_token: def github_api_request( - bot: GitHubTransportContext, + bot: GitHubApiContext, method: str, endpoint: str, data: dict | None = None, @@ -241,7 +241,7 @@ def github_api(bot: GitHubTransportContext, method: str, endpoint: str, data: di def github_graphql_request( - bot: GitHubTransportContext, + bot: GitHubApiContext, query: str, variables: dict | None = None, *, diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index e20413583..87cb3b703 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -17,10 +17,10 @@ LOCK_RETRY_BASE_SECONDS, LeaseContext, ) -from .context import LeaseLockContext +from .context import LeaseLockContext, LeaseLockRuntimeContext -def _log(bot: LeaseLockContext, level: str, message: str, **fields: Any) -> None: +def _log(bot: LeaseLockRuntimeContext, level: str, message: str, **fields: Any) -> None: logger = getattr(bot, "logger", None) if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) @@ -29,7 +29,7 @@ def _log(bot: LeaseLockContext, level: str, message: str, **fields: Any) -> None stream.write(f"{message}\n") -def _sleep(bot: LeaseLockContext, seconds: float) -> None: +def _sleep(bot: LeaseLockRuntimeContext, seconds: float) -> None: sleeper = getattr(bot, "sleeper", None) if sleeper is not None and hasattr(sleeper, "sleep"): sleeper.sleep(seconds) @@ -37,14 +37,14 @@ def _sleep(bot: LeaseLockContext, seconds: float) -> None: __import__("time").sleep(seconds) -def _jitter(bot: LeaseLockContext, lower: float, upper: float) -> float: +def _jitter(bot: LeaseLockRuntimeContext, lower: float, upper: float) -> float: jitter = getattr(bot, "jitter", None) if jitter is not None and hasattr(jitter, "uniform"): return jitter.uniform(lower, upper) return __import__("random").uniform(lower, upper) -def _retry_delay(bot: LeaseLockContext, base_seconds: float, retry_attempt: int) -> float: +def _retry_delay(bot: LeaseLockRuntimeContext, base_seconds: float, retry_attempt: int) -> float: class _BotJitter: def uniform(self, lower: float, upper: float) -> float: return _jitter(bot, lower, upper) @@ -56,7 +56,7 @@ def uniform(self, lower: float, upper: float) -> float: ) -def _now(bot: LeaseLockContext) -> datetime: +def _now(bot: LeaseLockRuntimeContext) -> datetime: clock = getattr(bot, "clock", None) if clock is not None and hasattr(clock, "now"): return clock.now() @@ -65,49 +65,49 @@ def _now(bot: LeaseLockContext) -> datetime: return datetime_module.now(timezone_module.utc) -def _monotonic(bot: LeaseLockContext) -> float: +def _monotonic(bot: LeaseLockRuntimeContext) -> float: time_module = getattr(bot, "time", None) if time_module is not None and hasattr(time_module, "monotonic"): return time_module.monotonic() return __import__("time").monotonic() -def _uuid4_hex(bot: LeaseLockContext) -> str: +def _uuid4_hex(bot: LeaseLockRuntimeContext) -> str: source = getattr(bot, "uuid_source", None) if source is not None and hasattr(source, "uuid4_hex"): return source.uuid4_hex() return __import__("uuid").uuid4().hex -def _lock_lease_ttl_seconds(bot: LeaseLockContext) -> int: +def _lock_lease_ttl_seconds(bot: LeaseLockRuntimeContext) -> int: accessor = getattr(bot, "lock_lease_ttl_seconds", None) if callable(accessor): return accessor() return getattr(bot, "LOCK_LEASE_TTL_SECONDS", LOCK_LEASE_TTL_SECONDS) -def _lock_api_retry_limit(bot: LeaseLockContext) -> int: +def _lock_api_retry_limit(bot: LeaseLockRuntimeContext) -> int: accessor = getattr(bot, "lock_api_retry_limit", None) if callable(accessor): return accessor() return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) -def _lock_retry_base_seconds(bot: LeaseLockContext) -> float: +def _lock_retry_base_seconds(bot: LeaseLockRuntimeContext) -> float: accessor = getattr(bot, "lock_retry_base_seconds", None) if callable(accessor): return accessor() return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) -def _lock_max_wait_seconds(bot: LeaseLockContext) -> int: +def _lock_max_wait_seconds(bot: LeaseLockRuntimeContext) -> int: accessor = getattr(bot, "lock_max_wait_seconds", None) if callable(accessor): return accessor() return getattr(bot, "LOCK_MAX_WAIT_SECONDS", LOCK_MAX_WAIT_SECONDS) -def _lock_renewal_window_seconds(bot: LeaseLockContext) -> int: +def _lock_renewal_window_seconds(bot: LeaseLockRuntimeContext) -> int: accessor = getattr(bot, "lock_renewal_window_seconds", None) if callable(accessor): return accessor() diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 0de7b2074..3e3055e89 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -26,10 +26,10 @@ StateIssueBodyParts, StateIssueSnapshot, ) -from .context import StateStoreContext +from .context import StateStoreContext, StateStoreRuntimeContext -def _log(bot: StateStoreContext, level: str, message: str, **fields: Any) -> None: +def _log(bot: StateStoreRuntimeContext, level: str, message: str, **fields: Any) -> None: logger = getattr(bot, "logger", None) if logger is not None and hasattr(logger, "event"): logger.event(level, message, **fields) @@ -42,7 +42,7 @@ def _log_fallback(level: str, message: str) -> None: sys.stderr.write(f"{message}\n") -def _sleep(bot: StateStoreContext, seconds: float) -> None: +def _sleep(bot: StateStoreRuntimeContext, seconds: float) -> None: sleeper = getattr(bot, "sleeper", None) if sleeper is not None and hasattr(sleeper, "sleep"): sleeper.sleep(seconds) @@ -50,14 +50,14 @@ def _sleep(bot: StateStoreContext, seconds: float) -> None: __import__("time").sleep(seconds) -def _jitter(bot: StateStoreContext, lower: float, upper: float) -> float: +def _jitter(bot: StateStoreRuntimeContext, lower: float, upper: float) -> float: jitter = getattr(bot, "jitter", None) if jitter is not None and hasattr(jitter, "uniform"): return jitter.uniform(lower, upper) return __import__("random").uniform(lower, upper) -def _retry_delay(bot: StateStoreContext, base_seconds: float, retry_attempt: int) -> float: +def _retry_delay(bot: StateStoreRuntimeContext, base_seconds: float, retry_attempt: int) -> float: class _BotJitter: def uniform(self, lower: float, upper: float) -> float: return _jitter(bot, lower, upper) @@ -69,28 +69,28 @@ def uniform(self, lower: float, upper: float) -> float: ) -def _now_iso(bot: StateStoreContext) -> str: +def _now_iso(bot: StateStoreRuntimeContext) -> str: clock = getattr(bot, "clock", None) if clock is not None and hasattr(clock, "now"): return clock.now().isoformat() return datetime.now(timezone.utc).isoformat() -def _state_issue_number(bot: StateStoreContext) -> int: +def _state_issue_number(bot: StateStoreRuntimeContext) -> int: accessor = getattr(bot, "state_issue_number", None) if callable(accessor): return accessor() return getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) -def _lock_api_retry_limit(bot: StateStoreContext) -> int: +def _lock_api_retry_limit(bot: StateStoreRuntimeContext) -> int: accessor = getattr(bot, "lock_api_retry_limit", None) if callable(accessor): return accessor() return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) -def _lock_retry_base_seconds(bot: StateStoreContext) -> float: +def _lock_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: accessor = getattr(bot, "lock_retry_base_seconds", None) if callable(accessor): return accessor() diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index d5e9289cb..52a2bcf5b 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -11,6 +11,7 @@ from urllib.parse import quote from . import retrying +from .context import SweeperContext from .reconcile import ( _artifact_expected_name, _artifact_expected_payload_name, @@ -37,11 +38,11 @@ def _now_iso() -> str: return _now().isoformat() -def _retention_days(bot) -> int: +def _retention_days(bot: SweeperContext) -> int: return int(bot.get_config_value("DEFERRED_ARTIFACT_RETENTION_DAYS", "7") or 7) -def _github_repository(bot) -> str: +def _github_repository(bot: SweeperContext) -> str: return bot.get_config_value("GITHUB_REPOSITORY", "") @@ -58,7 +59,7 @@ def parse_timestamp(value: Any) -> datetime | None: return None -def _read_api_payload(bot, endpoint: str) -> tuple[Any | None, str | None]: +def _read_api_payload(bot: SweeperContext, endpoint: str) -> tuple[Any | None, str | None]: try: response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read", suppress_error_log=True) except SystemExit: @@ -69,7 +70,7 @@ def _read_api_payload(bot, endpoint: str) -> tuple[Any | None, str | None]: return response.payload, None -def _download_retry_delay(bot, retry_attempt: int) -> float: +def _download_retry_delay(bot: SweeperContext, retry_attempt: int) -> float: base = float(getattr(bot, "LOCK_RETRY_BASE_SECONDS", 2.0)) class _BotJitter: @@ -121,7 +122,7 @@ def classify_artifact_gap_reason(gap: dict, now: datetime | None = None, *, rete def correlate_candidate_observer_runs( - bot, + bot: SweeperContext, source_event_key: str, *, source_event_kind: str, @@ -257,7 +258,7 @@ def correlate_run_artifacts_exact( } -def _fetch_workflow_runs_for_file(bot, workflow_file: str, event_name: str) -> list[dict] | None: +def _fetch_workflow_runs_for_file(bot: SweeperContext, workflow_file: str, event_name: str) -> list[dict] | None: runs: list[dict] = [] page = 1 encoded_workflow = quote(workflow_file, safe="") @@ -277,14 +278,14 @@ def _fetch_workflow_runs_for_file(bot, workflow_file: str, event_name: str) -> l page += 1 -def _fetch_run_detail(bot, run_id: int) -> dict | None: +def _fetch_run_detail(bot: SweeperContext, run_id: int) -> dict | None: response, _ = _read_api_payload(bot, f"actions/runs/{run_id}") if isinstance(response, dict): return response return None -def _list_run_artifacts(bot, run_id: int) -> list[dict] | None: +def _list_run_artifacts(bot: SweeperContext, run_id: int) -> list[dict] | None: artifacts: list[dict] = [] page = 1 while True: @@ -300,7 +301,7 @@ def _list_run_artifacts(bot, run_id: int) -> list[dict] | None: page += 1 -def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) -> tuple[str, dict | None]: +def _download_artifact_payload(bot: SweeperContext, artifact: dict, expected_payload_name: str) -> tuple[str, dict | None]: if artifact.get("expired") is True: return "expired", None download_url = artifact.get("archive_download_url") @@ -347,7 +348,7 @@ def _download_artifact_payload(bot, artifact: dict, expected_payload_name: str) return "ok", payload -def inspect_run_artifact_payloads(bot, workflow_runs: list[dict], source_event_key: str, *, pr_number: int, source_event_kind: str) -> dict: +def inspect_run_artifact_payloads(bot: SweeperContext, workflow_runs: list[dict], source_event_key: str, *, pr_number: int, source_event_kind: str) -> dict: payloads_by_run: dict[int, list[dict]] = {} prior_visibility: dict[int, dict[str, str]] = {} artifact_scan_outcomes: dict[int, str] = {} diff --git a/tests/contract/reviewer_bot/test_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py index 9d8a7ca05..8a2b4087c 100644 --- a/tests/contract/reviewer_bot/test_runtime_protocols.py +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -15,6 +15,25 @@ from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +def _assert_core_runtime_surface(runtime) -> None: + assert hasattr(runtime, "get_config_value") + assert hasattr(runtime, "get_github_token") + assert hasattr(runtime, "get_github_graphql_token") + assert hasattr(runtime, "rest_transport") + assert hasattr(runtime, "graphql_transport") + assert hasattr(runtime, "artifact_download_transport") + assert hasattr(runtime, "logger") + assert hasattr(runtime, "state_issue_number") + assert hasattr(runtime, "lock_api_retry_limit") + assert hasattr(runtime, "lock_retry_base_seconds") + assert hasattr(runtime, "lock_lease_ttl_seconds") + assert hasattr(runtime, "lock_max_wait_seconds") + assert hasattr(runtime, "lock_renewal_window_seconds") + assert hasattr(runtime, "lock_ref_name") + assert hasattr(runtime, "lock_ref_bootstrap_branch") + assert hasattr(runtime, "get_pull_request_reviews") + + def test_runtime_bot_returns_concrete_runtime_object(): runtime = reviewer_bot._runtime_bot() @@ -31,6 +50,7 @@ def test_runtime_object_satisfies_runtime_context_protocols(): assert isinstance(runtime, EventHandlerContext) assert isinstance(runtime, ProjectBoardMetadataContext) assert isinstance(runtime, ProjectBoardProjectionContext) + _assert_core_runtime_surface(runtime) def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): @@ -39,3 +59,4 @@ def test_fake_runtime_satisfies_app_execution_runtime_protocol(monkeypatch): assert isinstance(runtime, AppEventContextRuntime) assert isinstance(runtime, AppExecutionRuntime) assert isinstance(runtime, EventInputsContext) + _assert_core_runtime_surface(runtime) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index a44c693d2..60dfb98dd 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -7,6 +7,7 @@ from copy import deepcopy from datetime import datetime, timezone from typing import Any, Callable +from urllib.parse import urlparse from scripts.reviewer_bot_lib import automation as automation_module from scripts.reviewer_bot_lib import commands as commands_module @@ -140,6 +141,58 @@ def github_api_request(self, method: str, endpoint: str, data=None, extra_header ) +class RestTransportStub: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def request(self, method: str, url: str, *, headers=None, json_data=None, timeout_seconds=None): + del headers, timeout_seconds + parsed = urlparse(url) + parts = parsed.path.strip("/").split("/") + if len(parts) >= 4 and parts[0] == "repos": + endpoint = "/".join(parts[3:]) + else: + endpoint = parsed.path.lstrip("/") + result = self._runtime.github_api_request(method, endpoint, data=json_data) + + class _Response: + def __init__(self, api_result): + self.status_code = api_result.status_code or 0 + self.headers = api_result.headers + self.text = api_result.text + self.content = b"" if api_result.payload is None else json.dumps(api_result.payload).encode("utf-8") + self._payload = api_result.payload + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + return _Response(result) + + +class GraphQLTransportStub: + def __init__(self): + self._query: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) + + def stub(self, func: Callable[..., Any]) -> None: + self._query = func + + def query(self, url: str, *, headers=None, query: str, variables=None, timeout_seconds=None): + return self._query(url=url, headers=headers, query=query, variables=variables, timeout_seconds=timeout_seconds) + + +class ArtifactDownloadTransportStub: + def __init__(self): + self._download: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) + + def stub(self, func: Callable[..., Any]) -> None: + self._download = func + + def download(self, url: str, *, headers=None, timeout_seconds=None): + return self._download(url=url, headers=headers, timeout_seconds=timeout_seconds) + + class HandlerStub: ALLOWED = { "handle_issue_or_pr_opened", @@ -225,6 +278,9 @@ def __init__(self, monkeypatch, *, github=None): self.state_store = StateStoreStub() self.locks = LockStub() self.github = GitHubStub(github) + self.rest_transport = RestTransportStub(self) + self.graphql_transport = GraphQLTransportStub() + self.artifact_download_transport = ArtifactDownloadTransportStub() self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -250,6 +306,36 @@ def get_config_value(self, name: str, default: str = "") -> str: def set_config_value(self, name: str, value: Any) -> None: self.config.set(name, value) + def get_github_token(self) -> str: + token = self.get_config_value("GITHUB_TOKEN") + if not token: + raise SystemExit(1) + return token + + def state_issue_number(self) -> int: + return int(self.get_config_value("STATE_ISSUE_NUMBER", "0") or 0) + + def lock_api_retry_limit(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", "5") or 0) + + def lock_retry_base_seconds(self) -> float: + return float(self.get_config_value("REVIEWER_BOT_LOCK_RETRY_SECONDS", "2.0") or 0.0) + + def lock_lease_ttl_seconds(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_TTL_SECONDS", "300") or 0) + + def lock_max_wait_seconds(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", "120") or 0) + + def lock_renewal_window_seconds(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", "60") or 0) + + def lock_ref_name(self) -> str: + return self.get_config_value("REVIEWER_BOT_LOCK_REF_NAME", "refs/heads/reviewer-bot-lock") + + def lock_ref_bootstrap_branch(self) -> str: + return self.get_config_value("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", "main") + def write_output(self, name: str, value: str) -> None: self.outputs.write(name, value) From f686f19a0162a27a5595b002c28fc695a6253ce4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:41:34 +0900 Subject: [PATCH 150/209] refactor(reviewer-bot): compose runtime from explicit services --- scripts/reviewer_bot_lib/runtime.py | 97 +++++++++++++++---- .../reviewer_bot/test_adapter_contract.py | 22 +++++ 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 28999780d..cfdcf2817 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -171,6 +171,46 @@ def download( return self._requests.request("GET", url, headers=headers, timeout=timeout_seconds) +class RuntimeInfraServices: + def __init__( + self, + *, + config: Any, + outputs: Any, + deferred_payloads: Any, + rest_transport: Any, + graphql_transport: Any, + artifact_download_transport: Any, + clock: Any, + sleeper: Any, + jitter: Any, + uuid_source: Any, + logger: Any, + touch_tracker: Any, + ): + self.config = config + self.outputs = outputs + self.deferred_payloads = deferred_payloads + self.rest_transport = rest_transport + self.graphql_transport = graphql_transport + self.artifact_download_transport = artifact_download_transport + self.clock = clock + self.sleeper = sleeper + self.jitter = jitter + self.uuid_source = uuid_source + self.logger = logger + self.touch_tracker = touch_tracker + + +class RuntimeDomainServices: + def __init__(self, *, state_store: Any, github: Any, locks: Any, handlers: Any, adapters: Any): + self.state_store = state_store + self.github = github + self.locks = locks + self.handlers = handlers + self.adapters = adapters + + class ReviewerBotRuntime: """Runtime object built from explicit services and named adapters.""" @@ -223,23 +263,46 @@ def __init__( self.sys = sys self.random = random self.time = time - self.config = config or _EnvConfig() - self.outputs = outputs or _FileOutputSink(self.config) - self.deferred_payloads = deferred_payloads or _JsonDeferredPayloadLoader(self.config) - self.rest_transport = rest_transport or RequestsRestTransport(requests) - self.graphql_transport = graphql_transport or RequestsGraphQLTransport(requests) - self.artifact_download_transport = artifact_download_transport or RequestsArtifactDownloadTransport(requests) - self.clock = clock or SystemClock() - self.sleeper = sleeper or SystemSleeper(time) - self.jitter = jitter or RandomJitterSource(random) - self.uuid_source = uuid_source or Uuid4Source() - self.logger = logger or StdErrLogger(sys) - self.state_store = state_store - self.github = github - self.locks = locks - self.handlers = handlers - self.adapters = adapters - self.touch_tracker = touch_tracker or _TouchTracker() + resolved_config = config or _EnvConfig() + resolved_touch_tracker = touch_tracker or _TouchTracker() + self.infra = RuntimeInfraServices( + config=resolved_config, + outputs=outputs or _FileOutputSink(resolved_config), + deferred_payloads=deferred_payloads or _JsonDeferredPayloadLoader(resolved_config), + rest_transport=rest_transport or RequestsRestTransport(requests), + graphql_transport=graphql_transport or RequestsGraphQLTransport(requests), + artifact_download_transport=artifact_download_transport or RequestsArtifactDownloadTransport(requests), + clock=clock or SystemClock(), + sleeper=sleeper or SystemSleeper(time), + jitter=jitter or RandomJitterSource(random), + uuid_source=uuid_source or Uuid4Source(), + logger=logger or StdErrLogger(sys), + touch_tracker=resolved_touch_tracker, + ) + self.domain = RuntimeDomainServices( + state_store=state_store, + github=github, + locks=locks, + handlers=handlers, + adapters=adapters, + ) + self.config = self.infra.config + self.outputs = self.infra.outputs + self.deferred_payloads = self.infra.deferred_payloads + self.rest_transport = self.infra.rest_transport + self.graphql_transport = self.infra.graphql_transport + self.artifact_download_transport = self.infra.artifact_download_transport + self.clock = self.infra.clock + self.sleeper = self.infra.sleeper + self.jitter = self.infra.jitter + self.uuid_source = self.infra.uuid_source + self.logger = self.infra.logger + self.touch_tracker = self.infra.touch_tracker + self.state_store = self.domain.state_store + self.github = self.domain.github + self.locks = self.domain.locks + self.handlers = self.domain.handlers + self.adapters = self.domain.adapters self.ACTIVE_LEASE_CONTEXT = active_lease_context def get_config_value(self, name: str, default: str = "") -> str: diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 7e8921a4c..081751fc1 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -269,6 +269,28 @@ def test_runtime_accepts_injected_infra_services(): assert runtime.graphql_transport is graphql_transport +def test_runtime_exposes_explicit_infra_and_domain_service_groups(): + runtime = reviewer_bot._runtime_bot() + + assert runtime.infra.config is runtime.config + assert runtime.infra.outputs is runtime.outputs + assert runtime.infra.deferred_payloads is runtime.deferred_payloads + assert runtime.infra.rest_transport is runtime.rest_transport + assert runtime.infra.graphql_transport is runtime.graphql_transport + assert runtime.infra.artifact_download_transport is runtime.artifact_download_transport + assert runtime.infra.clock is runtime.clock + assert runtime.infra.sleeper is runtime.sleeper + assert runtime.infra.jitter is runtime.jitter + assert runtime.infra.uuid_source is runtime.uuid_source + assert runtime.infra.logger is runtime.logger + assert runtime.infra.touch_tracker is runtime.touch_tracker + assert runtime.domain.state_store is runtime.state_store + assert runtime.domain.github is runtime.github + assert runtime.domain.locks is runtime.locks + assert runtime.domain.handlers is runtime.handlers + assert runtime.domain.adapters is runtime.adapters + + def test_default_stderr_logger_renders_message_and_sorted_fields(): writes = [] logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) From 9bc2d1f1cb05efd67fd701d6bf402a53e4cc58fa Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:44:28 +0900 Subject: [PATCH 151/209] refactor(reviewer-bot): make bootstrap config services explicit --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 13 ++++++++++++- .../contract/reviewer_bot/test_adapter_contract.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 0fb118878..875324f62 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -26,11 +26,19 @@ reposition_member_as_next, sync_members_with_queue, ) -from .runtime import ReviewerBotRuntime +from .runtime import ( + ReviewerBotRuntime, + _EnvConfig, + _FileOutputSink, + _JsonDeferredPayloadLoader, +) def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: runtime: ReviewerBotRuntime | None = None + config_service = _EnvConfig() + output_sink = _FileOutputSink(config_service) + deferred_payload_loader = _JsonDeferredPayloadLoader(config_service) state_store_services = SimpleNamespace( load_state=lambda *, fail_on_unavailable=False: state_store.load_state( @@ -211,6 +219,9 @@ def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> sys=sys, random=random, time=time, + config=config_service, + outputs=output_sink, + deferred_payloads=deferred_payload_loader, state_store=state_store_services, github=github_services, locks=lock_services, diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 081751fc1..091495106 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -291,6 +291,17 @@ def test_runtime_exposes_explicit_infra_and_domain_service_groups(): assert runtime.domain.adapters is runtime.adapters +def test_bootstrap_runtime_wires_explicit_config_output_and_deferred_services(): + runtime = reviewer_bot._runtime_bot() + + assert runtime.config is runtime.infra.config + assert runtime.outputs is runtime.infra.outputs + assert runtime.deferred_payloads is runtime.infra.deferred_payloads + assert runtime.config.__class__.__name__ == "_EnvConfig" + assert runtime.outputs.__class__.__name__ == "_FileOutputSink" + assert runtime.deferred_payloads.__class__.__name__ == "_JsonDeferredPayloadLoader" + + def test_default_stderr_logger_renders_message_and_sorted_fields(): writes = [] logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) From 3b61a13e3769e81dc762477153c316c01654a3da Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:47:57 +0900 Subject: [PATCH 152/209] refactor(reviewer-bot): make bootstrap transport and state wiring explicit --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 57 ++++++++++++++----- .../reviewer_bot/test_adapter_contract.py | 11 ++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 875324f62..73d4bb81c 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -34,25 +34,56 @@ ) +class _BootstrapStateStoreServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def load_state(self, *, fail_on_unavailable: bool = False): + return state_store.load_state(self._runtime_getter(), fail_on_unavailable=fail_on_unavailable) + + def save_state(self, current_state): + return state_store.save_state(self._runtime_getter(), current_state) + + +class _BootstrapGitHubServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def github_api_request(self, *args, **kwargs): + return github_api.github_api_request(self._runtime_getter(), *args, **kwargs) + + def github_api(self, *args, **kwargs): + return github_api.github_api(self._runtime_getter(), *args, **kwargs) + + +class _BootstrapLockServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def acquire(self): + return lease_lock.acquire_state_issue_lease_lock(self._runtime_getter()) + + def release(self) -> bool: + return lease_lock.release_state_issue_lease_lock(self._runtime_getter()) + + def refresh(self) -> bool: + return lease_lock.ensure_state_issue_lease_lock_fresh(self._runtime_getter()) + + def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: runtime: ReviewerBotRuntime | None = None + + def runtime_getter() -> ReviewerBotRuntime: + assert runtime is not None + return runtime + config_service = _EnvConfig() output_sink = _FileOutputSink(config_service) deferred_payload_loader = _JsonDeferredPayloadLoader(config_service) - state_store_services = SimpleNamespace( - load_state=lambda *, fail_on_unavailable=False: state_store.load_state( - runtime, fail_on_unavailable=fail_on_unavailable - ), - save_state=lambda current_state: state_store.save_state(runtime, current_state), - ) - github_services = SimpleNamespace( - github_api_request=lambda *args, **kwargs: github_api.github_api_request( - runtime, *args, **kwargs - ), - github_api=lambda *args, **kwargs: github_api.github_api(runtime, *args, **kwargs), - ) - lock_services = SimpleNamespace() + state_store_services = _BootstrapStateStoreServices(runtime_getter) + github_services = _BootstrapGitHubServices(runtime_getter) + lock_services = _BootstrapLockServices(runtime_getter) handlers = SimpleNamespace( handle_issue_or_pr_opened=lambda current_state: lifecycle.handle_issue_or_pr_opened(runtime, current_state), handle_labeled_event=lambda current_state: lifecycle.handle_labeled_event(runtime, current_state), diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 091495106..b9c7de256 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -302,6 +302,17 @@ def test_bootstrap_runtime_wires_explicit_config_output_and_deferred_services(): assert runtime.deferred_payloads.__class__.__name__ == "_JsonDeferredPayloadLoader" +def test_bootstrap_runtime_wires_explicit_state_github_and_lock_services(): + runtime = reviewer_bot._runtime_bot() + + assert runtime.state_store.__class__.__name__ == "_BootstrapStateStoreServices" + assert runtime.github.__class__.__name__ == "_BootstrapGitHubServices" + assert runtime.locks.__class__.__name__ == "_BootstrapLockServices" + assert hasattr(runtime.locks, "acquire") + assert hasattr(runtime.locks, "release") + assert hasattr(runtime.locks, "refresh") + + def test_default_stderr_logger_renders_message_and_sorted_fields(): writes = [] logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) From dbae7fd9a7e3ed89111108736f3f22dfc99158b9 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:50:18 +0900 Subject: [PATCH 153/209] refactor(reviewer-bot): make bootstrap handler wiring explicit --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 50 +++++++++++++------ .../reviewer_bot/test_adapter_contract.py | 9 ++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 73d4bb81c..5d77f8e35 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -70,6 +70,41 @@ def refresh(self) -> bool: return lease_lock.ensure_state_issue_lease_lock_fresh(self._runtime_getter()) +class _BootstrapHandlerServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def handle_issue_or_pr_opened(self, current_state): + return lifecycle.handle_issue_or_pr_opened(self._runtime_getter(), current_state) + + def handle_labeled_event(self, current_state): + return lifecycle.handle_labeled_event(self._runtime_getter(), current_state) + + def handle_issue_edited_event(self, current_state): + return lifecycle.handle_issue_edited_event(self._runtime_getter(), current_state) + + def handle_closed_event(self, current_state): + return lifecycle.handle_closed_event(self._runtime_getter(), current_state) + + def handle_pull_request_target_synchronize(self, current_state): + return lifecycle.handle_pull_request_target_synchronize(self._runtime_getter(), current_state) + + def handle_pull_request_review_event(self, current_state): + return events.handle_pull_request_review_event(self._runtime_getter(), current_state) + + def handle_comment_event(self, current_state): + return comment_routing.handle_comment_event(self._runtime_getter(), current_state) + + def handle_manual_dispatch(self, current_state): + return maintenance.handle_manual_dispatch(self._runtime_getter(), current_state) + + def handle_scheduled_check(self, current_state): + return maintenance.handle_scheduled_check(self._runtime_getter(), current_state) + + def handle_workflow_run_event(self, current_state): + return reconcile.handle_workflow_run_event(self._runtime_getter(), current_state) + + def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: runtime: ReviewerBotRuntime | None = None @@ -84,20 +119,7 @@ def runtime_getter() -> ReviewerBotRuntime: state_store_services = _BootstrapStateStoreServices(runtime_getter) github_services = _BootstrapGitHubServices(runtime_getter) lock_services = _BootstrapLockServices(runtime_getter) - handlers = SimpleNamespace( - handle_issue_or_pr_opened=lambda current_state: lifecycle.handle_issue_or_pr_opened(runtime, current_state), - handle_labeled_event=lambda current_state: lifecycle.handle_labeled_event(runtime, current_state), - handle_issue_edited_event=lambda current_state: lifecycle.handle_issue_edited_event(runtime, current_state), - handle_closed_event=lambda current_state: lifecycle.handle_closed_event(runtime, current_state), - handle_pull_request_target_synchronize=lambda current_state: lifecycle.handle_pull_request_target_synchronize( - runtime, current_state - ), - handle_pull_request_review_event=lambda current_state: events.handle_pull_request_review_event(runtime, current_state), - handle_comment_event=lambda current_state: comment_routing.handle_comment_event(runtime, current_state), - handle_manual_dispatch=lambda current_state: maintenance.handle_manual_dispatch(runtime, current_state), - handle_scheduled_check=lambda current_state: maintenance.handle_scheduled_check(runtime, current_state), - handle_workflow_run_event=lambda current_state: reconcile.handle_workflow_run_event(runtime, current_state), - ) + handlers = _BootstrapHandlerServices(runtime_getter) adapters = SimpleNamespace( assert_lock_held=lambda context: state_store.assert_lock_held(runtime, context), get_github_token=lambda: github_api.get_github_token(runtime), diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index b9c7de256..fbf923bab 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -313,6 +313,15 @@ def test_bootstrap_runtime_wires_explicit_state_github_and_lock_services(): assert hasattr(runtime.locks, "refresh") +def test_bootstrap_runtime_wires_explicit_handler_services(): + runtime = reviewer_bot._runtime_bot() + + assert runtime.handlers.__class__.__name__ == "_BootstrapHandlerServices" + assert hasattr(runtime.handlers, "handle_issue_or_pr_opened") + assert hasattr(runtime.handlers, "handle_comment_event") + assert hasattr(runtime.handlers, "handle_workflow_run_event") + + def test_default_stderr_logger_renders_message_and_sorted_fields(): writes = [] logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) From faaa2f6452f2954dc8dbae8cba2e3030117d2220 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:55:01 +0900 Subject: [PATCH 154/209] test(reviewer-bot): add focused fake services --- .../test_fake_runtime_contract.py | 29 +++ tests/fixtures/fake_runtime.py | 212 ++---------------- tests/fixtures/focused_fake_services.py | 196 ++++++++++++++++ 3 files changed, 238 insertions(+), 199 deletions(-) create mode 100644 tests/fixtures/focused_fake_services.py diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 01f62af34..240c781b5 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -1,6 +1,19 @@ import pytest from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.focused_fake_services import ( + ArtifactDownloadTransportStub, + ConfigBag, + DeferredPayloadStore, + GitHubStub, + GraphQLTransportStub, + HandlerStub, + LockStub, + OutputCapture, + RestTransportStub, + StateStoreStub, + TouchTrackerStub, +) from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi pytestmark = pytest.mark.contract @@ -182,3 +195,19 @@ def test_fake_runtime_github_api_mode_delegates_to_shared_route_fake(monkeypatch runtime = FakeReviewerBotRuntime(monkeypatch, github=github) assert runtime.github_api("GET", "pulls/42") == {"head": {"sha": "head-1"}} + + +def test_focused_fake_service_types_are_exposed_for_direct_fixture_composition(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert isinstance(runtime.config, ConfigBag) + assert isinstance(runtime.outputs, OutputCapture) + assert isinstance(runtime.deferred_payloads, DeferredPayloadStore) + assert isinstance(runtime.state_store, StateStoreStub) + assert isinstance(runtime.github, GitHubStub) + assert isinstance(runtime.locks, LockStub) + assert isinstance(runtime.rest_transport, RestTransportStub) + assert isinstance(runtime.graphql_transport, GraphQLTransportStub) + assert isinstance(runtime.artifact_download_transport, ArtifactDownloadTransportStub) + assert isinstance(runtime.handlers, HandlerStub) + assert isinstance(runtime.touch_tracker, TouchTrackerStub) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 60dfb98dd..406e1d982 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -7,7 +7,6 @@ from copy import deepcopy from datetime import datetime, timezone from typing import Any, Callable -from urllib.parse import urlparse from scripts.reviewer_bot_lib import automation as automation_module from scripts.reviewer_bot_lib import commands as commands_module @@ -39,207 +38,22 @@ GitHubApiResult, ) from scripts.reviewer_bot_lib.context import LeaseContext +from tests.fixtures.focused_fake_services import ( + ArtifactDownloadTransportStub, + ConfigBag, + DeferredPayloadStore, + GitHubStub, + GraphQLTransportStub, + HandlerStub, + LockStub, + OutputCapture, + RestTransportStub, + StateStoreStub, + TouchTrackerStub, +) from tests.fixtures.recording_logger import RecordingLogger -class ConfigBag: - def __init__(self, monkeypatch): - self._monkeypatch = monkeypatch - self.values: dict[str, str] = {} - - def get(self, name: str, default: str = "") -> str: - return self.values.get(name, default) - - def set(self, name: str, value) -> None: - rendered = str(value) - self.values[name] = rendered - self._monkeypatch.setenv(name, rendered) - - -class OutputCapture: - def __init__(self): - self.writes: list[tuple[str, str]] = [] - - def write(self, name: str, value: str) -> None: - self.writes.append((name, value)) - - -class DeferredPayloadStore: - def __init__(self): - self._payload: dict = {} - - def set_payload(self, payload: dict) -> None: - self._payload = payload - - def load(self) -> dict: - return self._payload - - -class StateStoreStub: - def __init__(self): - self._load: Callable[..., dict] = lambda *, fail_on_unavailable=False: {"active_reviews": {}} - self._save: Callable[[dict], bool] = lambda state: True - - def stub_load(self, func: Callable[..., dict]) -> None: - self._load = func - - def stub_save(self, func: Callable[[dict], bool]) -> None: - self._save = func - - def load_state(self, *, fail_on_unavailable: bool = False) -> dict: - return self._load(fail_on_unavailable=fail_on_unavailable) - - def save_state(self, state: dict) -> bool: - return self._save(state) - - -class LockStub: - def __init__(self): - self._acquire: Callable[[], Any] = lambda: None - self._release: Callable[[], bool] = lambda: True - self._refresh: Callable[[], bool] = lambda: True - - def stub(self, *, acquire=None, release=None, refresh=None) -> None: - if acquire is not None: - self._acquire = acquire - if release is not None: - self._release = release - if refresh is not None: - self._refresh = refresh - - def acquire(self): - return self._acquire() - - def release(self) -> bool: - return self._release() - - def refresh(self) -> bool: - return self._refresh() - - -class GitHubStub: - def __init__(self, github=None): - self._github = github - - def stub(self, github) -> None: - self._github = github - - def github_api(self, method: str, endpoint: str, data=None): - if self._github is None: - raise AssertionError(f"No GitHub stub configured for {method} {endpoint}") - return self._github.github_api(method, endpoint, data=data) - - def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): - if self._github is None: - raise AssertionError(f"No GitHub request stub configured for {method} {endpoint}") - return self._github.github_api_request( - method, - endpoint, - data=data, - extra_headers=extra_headers, - **kwargs, - ) - - -class RestTransportStub: - def __init__(self, runtime: "FakeReviewerBotRuntime"): - self._runtime = runtime - - def request(self, method: str, url: str, *, headers=None, json_data=None, timeout_seconds=None): - del headers, timeout_seconds - parsed = urlparse(url) - parts = parsed.path.strip("/").split("/") - if len(parts) >= 4 and parts[0] == "repos": - endpoint = "/".join(parts[3:]) - else: - endpoint = parsed.path.lstrip("/") - result = self._runtime.github_api_request(method, endpoint, data=json_data) - - class _Response: - def __init__(self, api_result): - self.status_code = api_result.status_code or 0 - self.headers = api_result.headers - self.text = api_result.text - self.content = b"" if api_result.payload is None else json.dumps(api_result.payload).encode("utf-8") - self._payload = api_result.payload - - def json(self): - if isinstance(self._payload, Exception): - raise self._payload - return self._payload - - return _Response(result) - - -class GraphQLTransportStub: - def __init__(self): - self._query: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) - - def stub(self, func: Callable[..., Any]) -> None: - self._query = func - - def query(self, url: str, *, headers=None, query: str, variables=None, timeout_seconds=None): - return self._query(url=url, headers=headers, query=query, variables=variables, timeout_seconds=timeout_seconds) - - -class ArtifactDownloadTransportStub: - def __init__(self): - self._download: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) - - def stub(self, func: Callable[..., Any]) -> None: - self._download = func - - def download(self, url: str, *, headers=None, timeout_seconds=None): - return self._download(url=url, headers=headers, timeout_seconds=timeout_seconds) - - -class HandlerStub: - ALLOWED = { - "handle_issue_or_pr_opened", - "handle_labeled_event", - "handle_issue_edited_event", - "handle_closed_event", - "handle_pull_request_target_synchronize", - "handle_pull_request_review_event", - "handle_comment_event", - "handle_manual_dispatch", - "handle_scheduled_check", - "handle_workflow_run_event", - } - - def __init__(self, defaults: dict[str, Callable[[dict], bool]]): - self._handlers: dict[str, Callable[[dict], bool]] = defaults - - @staticmethod - def _missing_handler(name: str) -> Callable[[dict], bool]: - def fail(_state: dict) -> bool: - raise AssertionError(f"No handler stub configured for {name}") - - return fail - - def stub(self, name: str, func: Callable[[dict], bool]) -> None: - if name not in self.ALLOWED: - raise AssertionError(f"Unsupported runtime handler override: {name}") - self._handlers[name] = func - - def call(self, name: str, state: dict) -> bool: - return self._handlers[name](state) - - -class TouchTrackerStub: - def __init__(self): - self._touched: list[int] = [] - - def collect(self, issue_number: int | None) -> None: - if isinstance(issue_number, int) and issue_number not in self._touched: - self._touched.append(issue_number) - - def drain(self) -> list[int]: - items = list(self._touched) - self._touched.clear() - return items - - class FakeReviewerBotRuntime: BOT_NAME = BOT_NAME BOT_MENTION = BOT_MENTION diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py new file mode 100644 index 000000000..b91533f09 --- /dev/null +++ b/tests/fixtures/focused_fake_services.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +from typing import Any, Callable +from urllib.parse import urlparse + + +class ConfigBag: + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.values: dict[str, str] = {} + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + def set(self, name: str, value) -> None: + rendered = str(value) + self.values[name] = rendered + self._monkeypatch.setenv(name, rendered) + + +class OutputCapture: + def __init__(self): + self.writes: list[tuple[str, str]] = [] + + def write(self, name: str, value: str) -> None: + self.writes.append((name, value)) + + +class DeferredPayloadStore: + def __init__(self): + self._payload: dict = {} + + def set_payload(self, payload: dict) -> None: + self._payload = payload + + def load(self) -> dict: + return self._payload + + +class StateStoreStub: + def __init__(self): + self._load: Callable[..., dict] = lambda *, fail_on_unavailable=False: {"active_reviews": {}} + self._save: Callable[[dict], bool] = lambda state: True + + def stub_load(self, func: Callable[..., dict]) -> None: + self._load = func + + def stub_save(self, func: Callable[[dict], bool]) -> None: + self._save = func + + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + return self._load(fail_on_unavailable=fail_on_unavailable) + + def save_state(self, state: dict) -> bool: + return self._save(state) + + +class LockStub: + def __init__(self): + self._acquire: Callable[[], Any] = lambda: None + self._release: Callable[[], bool] = lambda: True + self._refresh: Callable[[], bool] = lambda: True + + def stub(self, *, acquire=None, release=None, refresh=None) -> None: + if acquire is not None: + self._acquire = acquire + if release is not None: + self._release = release + if refresh is not None: + self._refresh = refresh + + def acquire(self): + return self._acquire() + + def release(self) -> bool: + return self._release() + + def refresh(self) -> bool: + return self._refresh() + + +class GitHubStub: + def __init__(self, github=None): + self._github = github + + def stub(self, github) -> None: + self._github = github + + def github_api(self, method: str, endpoint: str, data=None): + if self._github is None: + raise AssertionError(f"No GitHub stub configured for {method} {endpoint}") + return self._github.github_api(method, endpoint, data=data) + + def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): + if self._github is None: + raise AssertionError(f"No GitHub request stub configured for {method} {endpoint}") + return self._github.github_api_request( + method, + endpoint, + data=data, + extra_headers=extra_headers, + **kwargs, + ) + + +class RestTransportStub: + def __init__(self, runtime): + self._runtime = runtime + + def request(self, method: str, url: str, *, headers=None, json_data=None, timeout_seconds=None): + del headers, timeout_seconds + parsed = urlparse(url) + parts = parsed.path.strip("/").split("/") + if len(parts) >= 4 and parts[0] == "repos": + endpoint = "/".join(parts[3:]) + else: + endpoint = parsed.path.lstrip("/") + result = self._runtime.github_api_request(method, endpoint, data=json_data) + + class _Response: + def __init__(self, api_result): + self.status_code = api_result.status_code or 0 + self.headers = api_result.headers + self.text = api_result.text + self.content = b"" if api_result.payload is None else json.dumps(api_result.payload).encode("utf-8") + self._payload = api_result.payload + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + return _Response(result) + + +class GraphQLTransportStub: + def __init__(self): + self._query: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) + + def stub(self, func: Callable[..., Any]) -> None: + self._query = func + + def query(self, url: str, *, headers=None, query: str, variables=None, timeout_seconds=None): + return self._query(url=url, headers=headers, query=query, variables=variables, timeout_seconds=timeout_seconds) + + +class ArtifactDownloadTransportStub: + def __init__(self): + self._download: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) + + def stub(self, func: Callable[..., Any]) -> None: + self._download = func + + def download(self, url: str, *, headers=None, timeout_seconds=None): + return self._download(url=url, headers=headers, timeout_seconds=timeout_seconds) + + +class HandlerStub: + ALLOWED = { + "handle_issue_or_pr_opened", + "handle_labeled_event", + "handle_issue_edited_event", + "handle_closed_event", + "handle_pull_request_target_synchronize", + "handle_pull_request_review_event", + "handle_comment_event", + "handle_manual_dispatch", + "handle_scheduled_check", + "handle_workflow_run_event", + } + + def __init__(self, defaults: dict[str, Callable[[dict], bool]]): + self._handlers: dict[str, Callable[[dict], bool]] = defaults + + def stub(self, name: str, func: Callable[[dict], bool]) -> None: + if name not in self.ALLOWED: + raise AssertionError(f"Unsupported runtime handler override: {name}") + self._handlers[name] = func + + def call(self, name: str, state: dict) -> bool: + return self._handlers[name](state) + + +class TouchTrackerStub: + def __init__(self): + self._touched: list[int] = [] + + def collect(self, issue_number: int | None) -> None: + if isinstance(issue_number, int) and issue_number not in self._touched: + self._touched.append(issue_number) + + def drain(self) -> list[int]: + items = list(self._touched) + self._touched.clear() + return items From 832da804c36bf59c7492dbb6a6637153b7281452 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:57:20 +0900 Subject: [PATCH 155/209] test(reviewer-bot): rebalance support-layer ownership contracts --- .../test_support_layer_ownership.py | 151 +++++++----------- 1 file changed, 55 insertions(+), 96 deletions(-) diff --git a/tests/contract/reviewer_bot/test_support_layer_ownership.py b/tests/contract/reviewer_bot/test_support_layer_ownership.py index b7b74cdb4..b0988696c 100644 --- a/tests/contract/reviewer_bot/test_support_layer_ownership.py +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -1,111 +1,70 @@ -from pathlib import Path - import pytest -from tests.fixtures import ( - http_responses, - reviewer_bot_env, - reviewer_bot_fakes, - reviewer_bot_recorders, +from tests.fixtures import http_responses, reviewer_bot_fakes, reviewer_bot_recorders +from tests.fixtures.focused_fake_services import ( + ArtifactDownloadTransportStub, + ConfigBag, + DeferredPayloadStore, + GitHubStub, + GraphQLTransportStub, + HandlerStub, + LockStub, + OutputCapture, + RestTransportStub, + StateStoreStub, + TouchTrackerStub, ) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result pytestmark = pytest.mark.contract -ROOT = Path(__file__).resolve().parents[3] - - -def _read(relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - -def test_support_layer_has_owned_env_and_recorder_modules(): - assert reviewer_bot_env is not None - assert reviewer_bot_recorders is not None - - -def test_support_layer_has_owned_typed_builder_home(): - builders_text = _read("tests/fixtures/reviewer_bot_builders.py") - - assert "def build_assignment_request(" in builders_text - assert "def build_privileged_command_request(" in builders_text - assert "def build_comment_event_request(" in builders_text - assert "def build_pr_comment_trust_context(" in builders_text - - -def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes(): - assert reviewer_bot_fakes.RouteGitHubApi is not None - assert reviewer_bot_fakes.github_result is not None +def test_transport_fake_authority_is_owned_by_reviewer_bot_fakes_module(): + assert reviewer_bot_fakes.RouteGitHubApi is RouteGitHubApi + assert reviewer_bot_fakes.github_result is github_result def test_low_level_http_response_helper_has_dedicated_home(): - response_text = _read("tests/fixtures/http_responses.py") - - assert "class FakeGitHubResponse:" in response_text - assert "RouteGitHubApi" not in response_text - assert "github_result" not in response_text + assert http_responses.FakeGitHubResponse is not None assert http_responses.__all__ == ["FakeGitHubResponse"] -def test_harnesses_do_not_define_local_config_bags_or_deferred_payload_loaders(): - for relative_path in [ - "tests/fixtures/app_harness.py", - "tests/fixtures/commands_harness.py", - "tests/fixtures/comment_routing_harness.py", - "tests/fixtures/reconcile_harness.py", - ]: - text = _read(relative_path) - assert "class _ConfigBag" not in text - assert "class _DeferredPayloads" not in text - - -def test_harnesses_use_owned_env_and_recorder_homes(): - app_harness = _read("tests/fixtures/app_harness.py") - commands_harness = _read("tests/fixtures/commands_harness.py") - comment_harness = _read("tests/fixtures/comment_routing_harness.py") - conftest = _read("tests/conftest.py") - - assert "from .reviewer_bot_env import" in app_harness or "reviewer_bot_env" in app_harness - assert "from .reviewer_bot_env import" in commands_harness or "reviewer_bot_env" in commands_harness - assert "from .reviewer_bot_env import" in comment_harness or "reviewer_bot_env" in comment_harness - assert "reviewer_bot_env" in conftest - assert "reviewer_bot_recorders" in commands_harness - assert "reviewer_bot_recorders" in comment_harness - assert "reviewer_bot_recorders" in conftest - - -def test_no_second_fake_runtime_or_transport_home_exists(): - fixtures_dir = ROOT / "tests/fixtures" - fixture_files = {path.name for path in fixtures_dir.glob("*.py")} - - assert "fake_runtime.py" in fixture_files - assert "reviewer_bot_fakes.py" in fixture_files - assert "commands_harness.py" in fixture_files - assert "comment_routing_harness.py" in fixture_files - assert "reconcile_harness.py" in fixture_files - assert "http_responses.py" in fixture_files - - -def test_support_layer_ownership_contract_targets_current_authority_hotspots_only(): - text = _read("tests/contract/reviewer_bot/test_support_layer_ownership.py") - - assert "RouteGitHubApi" in text - assert "github_result" in text - assert "FakeGitHubResponse" in text - - -def test_transport_fakes_are_not_imported_from_github_helper_module_anywhere_in_tests(): - for path in ROOT.glob("tests/**/*.py"): - if path.name == "test_support_layer_ownership.py": - continue - text = path.read_text(encoding="utf-8") - assert "from tests.fixtures.github import RouteGitHubApi" not in text - assert "from tests.fixtures.github import github_result" not in text +def test_reviewer_bot_recorders_module_remains_available_for_shared_recorders(): + assert reviewer_bot_recorders is not None -def test_fake_github_response_uses_dedicated_low_level_helper_home(): - for path in ROOT.glob("tests/**/*.py"): - if path.name == "test_support_layer_ownership.py": - continue - text = path.read_text(encoding="utf-8") - assert "from tests.fixtures.github import FakeGitHubResponse" not in text +def test_focused_fake_service_module_is_authority_for_small_fixture_services(): + expected = { + "ConfigBag": ConfigBag, + "OutputCapture": OutputCapture, + "DeferredPayloadStore": DeferredPayloadStore, + "StateStoreStub": StateStoreStub, + "LockStub": LockStub, + "GitHubStub": GitHubStub, + "RestTransportStub": RestTransportStub, + "GraphQLTransportStub": GraphQLTransportStub, + "ArtifactDownloadTransportStub": ArtifactDownloadTransportStub, + "HandlerStub": HandlerStub, + "TouchTrackerStub": TouchTrackerStub, + } + + for name, obj in expected.items(): + assert obj.__name__ == name + assert obj.__module__ == "tests.fixtures.focused_fake_services" + + +def test_support_layer_contract_focuses_on_active_authority_boundaries_only(): + active_authorities = { + RouteGitHubApi.__module__, + github_result.__module__, + http_responses.FakeGitHubResponse.__module__, + ConfigBag.__module__, + reviewer_bot_recorders.__name__, + } + + assert active_authorities == { + "tests.fixtures.reviewer_bot_fakes", + "tests.fixtures.http_responses", + "tests.fixtures.focused_fake_services", + "tests.fixtures.reviewer_bot_recorders", + } From fd77579c3a7265698ba415f92007e141284a1d12 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 4 Apr 2026 23:59:56 +0900 Subject: [PATCH 156/209] test(reviewer-bot): compose app harness from focused fakes --- tests/fixtures/app_harness.py | 12 ++++++++---- tests/integration/reviewer_bot/test_app_execution.py | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index f44d06702..73bdda27c 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -22,6 +22,10 @@ def __init__(self, monkeypatch): self.runtime = FakeReviewerBotRuntime(monkeypatch) self.config = self.runtime.config self.outputs = self.runtime.outputs + self.state_store = self.runtime.state_store + self.locks = self.runtime.locks + self.handlers = self.runtime.handlers + self.touch_tracker = self.runtime.touch_tracker self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_event(self, **values) -> None: @@ -31,16 +35,16 @@ def set_state_sequence(self, *states: dict) -> None: self.runtime.stub_state_sequence(*states) def stub_load_state(self, func) -> None: - self.runtime.state_store.stub_load(func) + self.state_store.stub_load(func) def stub_save_state(self, func) -> None: - self.runtime.state_store.stub_save(func) + self.state_store.stub_save(func) def stub_lock(self, *, acquire=None, release=None, refresh=None) -> None: - self.runtime.stub_lock(acquire=acquire, release=release, refresh=refresh) + self.locks.stub(acquire=acquire, release=release, refresh=refresh) def stub_handler(self, name: str, func) -> None: - self.runtime.handlers.stub(name, func) + self.handlers.stub(name, func) def stub_pass_until(self, func) -> None: self.runtime.stub_pass_until(func) diff --git a/tests/integration/reviewer_bot/test_app_execution.py b/tests/integration/reviewer_bot/test_app_execution.py index 067d87ead..02d2e3553 100644 --- a/tests/integration/reviewer_bot/test_app_execution.py +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -6,6 +6,15 @@ pytestmark = pytest.mark.integration +def test_app_harness_exposes_focused_runtime_services(monkeypatch): + harness = AppHarness(monkeypatch) + + assert harness.state_store is harness.runtime.state_store + assert harness.locks is harness.runtime.locks + assert harness.handlers is harness.runtime.handlers + assert harness.touch_tracker is harness.runtime.touch_tracker + + def test_execute_run_reloads_state_before_syncing_status_labels(monkeypatch): harness = AppHarness(monkeypatch) harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") From 63ee66dda1ddc0dba0a7243abfae67dfac7bc2a0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 00:03:42 +0900 Subject: [PATCH 157/209] test(reviewer-bot): narrow command comment and reconcile harnesses --- tests/fixtures/commands_harness.py | 4 +- tests/fixtures/comment_routing_harness.py | 1 + tests/fixtures/reconcile_harness.py | 3 +- tests/unit/reviewer_bot/test_commands.py | 2 + .../unit/reviewer_bot/test_comment_routing.py | 1 + .../unit/reviewer_bot/test_reconcile_unit.py | 45 ++++++++++++------- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py index b8b72ab58..0c71d5cff 100644 --- a/tests/fixtures/commands_harness.py +++ b/tests/fixtures/commands_harness.py @@ -44,6 +44,8 @@ def __init__(self, monkeypatch): self._monkeypatch = monkeypatch self.runtime = FakeReviewerBotRuntime(monkeypatch) self.config = self.runtime.config + self.github = self.runtime.github + self.handlers = self.runtime.handlers def wrapper_set_comment_command( self, @@ -119,7 +121,7 @@ def stub_permission(self, status: str) -> None: self.runtime.get_user_permission_status = lambda username, required_permission="triage": status def stub_handler(self, name: str, func) -> None: - self.runtime.handlers.stub(name, func) + self.handlers.stub(name, func) def automation_runner(self) -> AutomationRunner: runner = AutomationRunner() diff --git a/tests/fixtures/comment_routing_harness.py b/tests/fixtures/comment_routing_harness.py index 5aa7bd380..7c9dc7e53 100644 --- a/tests/fixtures/comment_routing_harness.py +++ b/tests/fixtures/comment_routing_harness.py @@ -19,6 +19,7 @@ def __init__(self, monkeypatch): self.github = RouteGitHubApi() self.runtime = FakeReviewerBotRuntime(monkeypatch, github=self.github) self.config = self.runtime.config + self.handlers = self.runtime.handlers def env_build_request(self, *, issue_number: int | None = None): return event_inputs.build_comment_event_request(self.runtime, issue_number=issue_number) diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py index a18997a3a..a62736fa0 100644 --- a/tests/fixtures/reconcile_harness.py +++ b/tests/fixtures/reconcile_harness.py @@ -124,6 +124,7 @@ def __post_init__(self) -> None: self.github = RouteGitHubApi() self.runtime = FakeReviewerBotRuntime(self.monkeypatch, github=self.github) self.config = self.runtime.config + self.deferred_payloads = self.runtime.deferred_payloads self.runtime.stub_deferred_payload(self.payload) self.wrapper_set_trigger_from_payload(self.payload) @@ -132,7 +133,7 @@ def handle_workflow_run_event(self, state: dict) -> bool: def set_payload(self, payload: dict) -> dict: self.payload = payload - self.runtime.stub_deferred_payload(payload) + self.deferred_payloads.set_payload(payload) self.wrapper_set_trigger_from_payload(payload) return payload diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index 58a3bb9d9..d92deea08 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -17,6 +17,8 @@ def test_label_signoff_create_pr_marks_issue_review_complete_without_inline_status_sync(monkeypatch): harness = CommandHarness(monkeypatch) + assert harness.github is harness.runtime.github + assert harness.handlers is harness.runtime.handlers state = make_state() review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 56edf45fb..e7f967a35 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -10,6 +10,7 @@ def test_record_conversation_freshness_returns_true_when_only_reviewer_activity_changes(monkeypatch): harness = CommentRoutingHarness(monkeypatch) + assert harness.handlers is harness.runtime.handlers state = make_state() review = review_state.ensure_review_entry(state, 42, create=True) assert review is not None diff --git a/tests/unit/reviewer_bot/test_reconcile_unit.py b/tests/unit/reviewer_bot/test_reconcile_unit.py index 3c796ea4f..f828d7d14 100644 --- a/tests/unit/reviewer_bot/test_reconcile_unit.py +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -3,26 +3,22 @@ from scripts.reviewer_bot_lib import commands, lifecycle, reconcile, review_state from scripts.reviewer_bot_lib.config import GitHubApiResult from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.reconcile_harness import ReconcileHarness, review_submitted_payload from tests.fixtures.reviewer_bot import make_state def test_parse_deferred_context_payload_returns_typed_review_payload(): - payload = { - "schema_version": 2, - "source_workflow_name": "Reviewer Bot PR Review Submitted Observer", - "source_workflow_file": ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - "source_run_id": 500, - "source_run_attempt": 2, - "source_event_name": "pull_request_review", - "source_event_action": "submitted", - "source_event_key": "pull_request_review:11", - "pr_number": 42, - "review_id": 11, - "source_submitted_at": "2026-03-17T10:00:00Z", - "source_review_state": "COMMENTED", - "source_commit_id": "head-1", - "actor_login": "alice", - } + payload = review_submitted_payload( + pr_number=42, + review_id=11, + source_event_key="pull_request_review:11", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, + ) parsed = reconcile.parse_deferred_context_payload(payload) @@ -313,3 +309,20 @@ def test_read_optional_reconcile_object_returns_none_for_not_found(monkeypatch): ) assert reconcile._read_optional_reconcile_object(runtime, "pulls/42/reviews/11", label="live review #11") is None + + +def test_reconcile_harness_exposes_deferred_payload_store(monkeypatch): + payload = review_submitted_payload( + pr_number=42, + review_id=11, + source_event_key="pull_request_review:11", + source_submitted_at="2026-03-17T10:00:00Z", + source_review_state="COMMENTED", + source_commit_id="head-1", + actor_login="alice", + source_run_id=500, + source_run_attempt=2, + ) + harness = ReconcileHarness(monkeypatch, payload) + + assert harness.deferred_payloads is harness.runtime.deferred_payloads From 95022906d67358aea0866282e2e7f017403fc75f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 00:06:57 +0900 Subject: [PATCH 158/209] test(reviewer-bot): shrink fake runtime to compatibility shell --- .../test_fake_runtime_contract.py | 19 ++++++++ tests/fixtures/fake_runtime.py | 44 +++++++++++++++++-- .../test_app_reviewer_board_preview.py | 12 ++++- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 240c781b5..e42a143e6 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -41,6 +41,8 @@ def test_fake_runtime_exposes_explicit_service_fields_and_no_omnibus_service_con assert runtime.locks is not None assert runtime.handlers is not None assert runtime.touch_tracker is not None + assert runtime.infra is not None + assert runtime.domain is not None assert hasattr(runtime, "services") is False assert hasattr(runtime, "components") is False @@ -211,3 +213,20 @@ def test_focused_fake_service_types_are_exposed_for_direct_fixture_composition(m assert isinstance(runtime.artifact_download_transport, ArtifactDownloadTransportStub) assert isinstance(runtime.handlers, HandlerStub) assert isinstance(runtime.touch_tracker, TouchTrackerStub) + + +def test_fake_runtime_groups_focused_services_into_infra_and_domain_shells(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert runtime.infra.config is runtime.config + assert runtime.infra.outputs is runtime.outputs + assert runtime.infra.deferred_payloads is runtime.deferred_payloads + assert runtime.infra.logger is runtime.logger + assert runtime.infra.rest_transport is runtime.rest_transport + assert runtime.infra.graphql_transport is runtime.graphql_transport + assert runtime.infra.artifact_download_transport is runtime.artifact_download_transport + assert runtime.infra.touch_tracker is runtime.touch_tracker + assert runtime.domain.state_store is runtime.state_store + assert runtime.domain.github is runtime.github + assert runtime.domain.locks is runtime.locks + assert runtime.domain.handlers is runtime.handlers diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 406e1d982..e938083aa 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -54,6 +54,26 @@ from tests.fixtures.recording_logger import RecordingLogger +class FakeRuntimeInfraServices: + def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport, graphql_transport, artifact_download_transport, touch_tracker): + self.config = config + self.outputs = outputs + self.deferred_payloads = deferred_payloads + self.logger = logger + self.rest_transport = rest_transport + self.graphql_transport = graphql_transport + self.artifact_download_transport = artifact_download_transport + self.touch_tracker = touch_tracker + + +class FakeRuntimeDomainServices: + def __init__(self, *, state_store, github, locks, handlers): + self.state_store = state_store + self.github = github + self.locks = locks + self.handlers = handlers + + class FakeReviewerBotRuntime: BOT_NAME = BOT_NAME BOT_MENTION = BOT_MENTION @@ -89,12 +109,13 @@ def __init__(self, monkeypatch, *, github=None): self.config = ConfigBag(monkeypatch) self.outputs = OutputCapture() self.deferred_payloads = DeferredPayloadStore() - self.state_store = StateStoreStub() - self.locks = LockStub() - self.github = GitHubStub(github) self.rest_transport = RestTransportStub(self) self.graphql_transport = GraphQLTransportStub() self.artifact_download_transport = ArtifactDownloadTransportStub() + self.touch_tracker = TouchTrackerStub() + self.state_store = StateStoreStub() + self.locks = LockStub() + self.github = GitHubStub(github) self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -109,7 +130,22 @@ def __init__(self, monkeypatch, *, github=None): "handle_workflow_run_event": lambda state: reconcile_module.handle_workflow_run_event(self, state), } ) - self.touch_tracker = TouchTrackerStub() + self.infra = FakeRuntimeInfraServices( + config=self.config, + outputs=self.outputs, + deferred_payloads=self.deferred_payloads, + logger=self.logger, + rest_transport=self.rest_transport, + graphql_transport=self.graphql_transport, + artifact_download_transport=self.artifact_download_transport, + touch_tracker=self.touch_tracker, + ) + self.domain = FakeRuntimeDomainServices( + state_store=self.state_store, + github=self.github, + locks=self.locks, + handlers=self.handlers, + ) self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False diff --git a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py index 0dd3b9888..ab336da76 100644 --- a/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -47,7 +47,11 @@ def test_execute_run_preview_reviewer_board_missing_token_fails_clearly(monkeypa result = harness.run_execute() assert result.exit_code == 1 - assert "REVIEWER_BOARD_TOKEN not set" in capsys.readouterr().err + captured = capsys.readouterr().err + assert "REVIEWER_BOARD_TOKEN not set" in captured or any( + "REVIEWER_BOARD_TOKEN not set" in record["message"] + for record in harness.runtime.logger.records + ) def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monkeypatch, capsys): harness = AppHarness(monkeypatch) @@ -81,7 +85,11 @@ def test_execute_run_preview_reviewer_board_invalid_manifest_fails_clearly(monke result = harness.run_execute() assert result.exit_code == 1 - assert "Missing reviewer board field: Review State" in capsys.readouterr().err + captured = capsys.readouterr().err + assert "Missing reviewer board field: Review State" in captured or any( + "Missing reviewer board field: Review State" in record["message"] + for record in harness.runtime.logger.records + ) def test_execute_run_preview_reviewer_board_is_read_only(monkeypatch, capsys): harness = AppHarness(monkeypatch) From fb4f33add9dc8de524f15d8165917c5b74224a26 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 00:19:43 +0900 Subject: [PATCH 159/209] test(reviewer-bot): remove module patching from github api tests --- scripts/reviewer_bot_lib/github_api.py | 38 ++++++++------ tests/unit/reviewer_bot/test_github_api.py | 58 ++++++---------------- 2 files changed, 37 insertions(+), 59 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index dd7c6fd93..18c28a458 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -24,7 +24,7 @@ class _RandomJitter: def uniform(self, lower: float, upper: float) -> float: - return __import__("random").uniform(lower, upper) + return random.uniform(lower, upper) def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: @@ -36,6 +36,22 @@ def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: print(message, file=stream) +def _sleep(bot: GitHubApiContext, seconds: float) -> None: + sleeper = getattr(bot, "sleeper", None) + if sleeper is not None and hasattr(sleeper, "sleep"): + sleeper.sleep(seconds) + return + time.sleep(seconds) + + +def _retry_delay(bot: GitHubApiContext, base_seconds: float, retry_attempt: int) -> float: + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=getattr(bot, "jitter", None) or _RandomJitter(), + ) + + def _should_retry_status(status_code: int | None) -> bool: return retrying.is_retryable_status(status_code) @@ -60,14 +76,6 @@ def _classify_failure(status_code: int | None, *, invalid_payload: bool = False, return None -def _retry_delay(base_seconds: float, retry_attempt: int) -> float: - return retrying.bounded_exponential_delay( - base_seconds, - retry_attempt, - jitter=_RandomJitter(), - ) - - def _validate_rest_retry_policy(method: str, retry_policy: str) -> None: retrying.additional_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) if retry_policy == RETRY_POLICY_NONE: @@ -165,7 +173,7 @@ def github_api_request( failure_kind = _classify_failure(None, transport_error=True) if attempt < max_attempts: retry_attempts += 1 - time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + _sleep(bot, _retry_delay(bot, LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: _log(bot, "error", f"GitHub API transport error: {exc}", transport_error=str(exc)) @@ -206,7 +214,7 @@ def github_api_request( failure_kind = _classify_failure(response.status_code, invalid_payload=invalid_payload) if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and _should_retry_status(response.status_code) and attempt < max_attempts: retry_attempts += 1 - time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + _sleep(bot, _retry_delay(bot, LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: @@ -273,7 +281,7 @@ def github_graphql_request( failure_kind = _classify_failure(None, transport_error=True) if retry_policy == RETRY_POLICY_IDEMPOTENT_READ and attempt < max_attempts: retry_attempts += 1 - time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + _sleep(bot, _retry_delay(bot, LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: _log(bot, "error", f"GitHub GraphQL transport error: {exc}", transport_error=str(exc)) @@ -318,7 +326,7 @@ def github_graphql_request( ) if _should_retry_status(response.status_code) and attempt < max_attempts: retry_attempts += 1 - time.sleep(_retry_delay(LOCK_RETRY_BASE_SECONDS, retry_attempts)) + _sleep(bot, _retry_delay(bot, LOCK_RETRY_BASE_SECONDS, retry_attempts)) continue if not suppress_error_log: @@ -491,7 +499,7 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, ) if response.status_code == 429 or response.status_code >= 500: if attempt < lock_api_retry_limit: - delay = lock_retry_base_seconds + random.uniform(0, lock_retry_base_seconds) + delay = _retry_delay(bot, lock_retry_base_seconds, attempt) _log( bot, "warning", @@ -501,7 +509,7 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, status_code=response.status_code, retry_attempt=attempt, ) - time.sleep(delay) + _sleep(bot, delay) continue return bot.AssignmentAttempt( success=False, diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index e6358d36f..5b78a9632 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -4,6 +4,8 @@ from scripts.reviewer_bot_lib import automation, github_api from scripts.reviewer_bot_lib.config import LOCK_API_RETRY_LIMIT, GitHubApiResult +from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_sleeper import RecordingSleeper from tests.fixtures.http_responses import FakeGitHubResponse from tests.fixtures.recording_logger import RecordingLogger @@ -50,15 +52,23 @@ def query(self, url, *, headers=None, query=None, variables=None, timeout_second return response -def _bot(**overrides): +def _bot(*, config=None, **overrides): + config_values = { + "REPO_OWNER": "rustfoundation", + "REPO_NAME": "safety-critical-rust-coding-guidelines", + "GITHUB_TOKEN": "token", + **(config or {}), + } bot = SimpleNamespace( GitHubApiResult=GitHubApiResult, get_github_token=lambda: "token", get_github_graphql_token=lambda prefer_board_token=False: "token", - get_config_value=lambda name, default="": __import__("os").environ.get(name, default), + get_config_value=lambda name, default="": config_values.get(name, default), rest_transport=RecordingRestTransport(), graphql_transport=RecordingGraphQLTransport(), logger=RecordingLogger(), + sleeper=RecordingSleeper(), + jitter=DeterministicJitter(0.0), STATE_ISSUE_NUMBER=1, ) for key, value in overrides.items(): @@ -67,16 +77,12 @@ def _bot(**overrides): def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True @@ -86,12 +92,9 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport( [github_api.requests.RequestException("timeout"), FakeGitHubResponse(200, {"ok": True}, "ok")] ) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") @@ -101,8 +104,6 @@ def test_github_api_request_retries_transport_exception_for_idempotent_get(monke def test_github_api_request_classifies_not_found_without_retry(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(404, {"message": "missing"}, "missing")]) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) @@ -114,8 +115,6 @@ def test_github_api_request_classifies_not_found_without_retry(monkeypatch): def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) @@ -147,16 +146,12 @@ def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport( [ FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), FakeGitHubResponse(200, {"ok": True}, "ok"), ] ) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True @@ -164,10 +159,7 @@ def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * (LOCK_API_RETRY_LIMIT + 1)) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) @@ -195,8 +187,6 @@ def test_github_graphql_request_reports_graphql_errors(monkeypatch): def test_github_api_request_passes_timeout(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(200, {"ok": True}, "ok")]) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", timeout_seconds=12.5) @@ -233,29 +223,24 @@ def test_get_user_permission_status_distinguishes_unavailable(): def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): - monkeypatch.setenv("IS_PULL_REQUEST", "true") - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None), github_api=lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + bot = _bot(config={"IS_PULL_REQUEST": "true"}, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None), github_api=lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) assert github_api.get_issue_assignees(bot, 42) is None def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=200, payload={"not": "a list"}, headers={}, text="ok", ok=True, failure_kind=None, retry_attempts=0, transport_error=None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42") @@ -265,10 +250,7 @@ def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypat def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway")] * (LOCK_API_RETRY_LIMIT + 1)) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) @@ -278,10 +260,7 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([github_api.requests.RequestException("timeout")] * (LOCK_API_RETRY_LIMIT + 1)) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) @@ -292,8 +271,6 @@ def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([github_api.requests.RequestException("timeout")]) bot = _bot(rest_transport=transport) @@ -305,8 +282,6 @@ def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch def test_github_api_request_logs_error_response_to_recording_logger(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") - monkeypatch.setenv("REPO_NAME", "safety-critical-rust-coding-guidelines") transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) bot = _bot(rest_transport=transport) @@ -327,15 +302,11 @@ def test_github_graphql_request_passes_timeout(monkeypatch): def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): - monkeypatch.delenv("REPO_OWNER", raising=False) - - assert automation.find_open_pr_for_branch_status(_bot(), "feature") == ("not_found", None) - monkeypatch.setenv("REPO_OWNER", "rustfoundation") + assert automation.find_open_pr_for_branch_status(_bot(config={"REPO_OWNER": ""}), "feature") == ("not_found", None) assert automation.find_open_pr_for_branch_status(_bot(), "") == ("not_found", None) def test_find_open_pr_for_branch_status_reports_found(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [{"number": 42, "html_url": "https://example.com/pr/42"}], {}, "ok", True, None, 0, None)) status, pr = automation.find_open_pr_for_branch_status(bot, "feature") @@ -345,7 +316,6 @@ def test_find_open_pr_for_branch_status_reports_found(monkeypatch): def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): - monkeypatch.setenv("REPO_OWNER", "rustfoundation") bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [], {}, "ok", True, None, 0, None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("not_found", None) From a42e20ecdfbcddf561df8b10e1898cc92ba096d6 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:40:40 +0900 Subject: [PATCH 160/209] test(reviewer-bot): remove module patching from lease lock tests --- tests/unit/reviewer_bot/test_lease_lock.py | 91 +++++++++++++--------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/tests/unit/reviewer_bot/test_lease_lock.py b/tests/unit/reviewer_bot/test_lease_lock.py index b5f6a69d3..a8a492e5f 100644 --- a/tests/unit/reviewer_bot/test_lease_lock.py +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -13,6 +13,11 @@ def _lease_bot(**overrides): + config_values = { + "WORKFLOW_RUN_ID": "local-run", + "WORKFLOW_NAME": "reviewer-bot", + "WORKFLOW_JOB_NAME": "reviewer-bot", + } bot = SimpleNamespace( ACTIVE_LEASE_CONTEXT=None, LOCK_REF_NAME="heads/reviewer-bot-state-lock", @@ -30,12 +35,15 @@ def _lease_bot(**overrides): logger=RecordingLogger(), github_api_request=lambda *args, **kwargs: None, get_state_issue_snapshot=lambda: SimpleNamespace(html_url="https://example.com/issues/314"), - get_config_value=lambda name, default="": default, + get_config_value=lambda name, default="": config_values.get(name, default), normalize_lock_metadata=state_store.normalize_lock_metadata, parse_iso8601_timestamp=state_store.parse_iso8601_timestamp, ) for key, value in overrides.items(): setattr(bot, key, value) + if "config_values" in overrides: + config_values.update(overrides["config_values"]) + delattr(bot, "config_values") bot.clear_lock_metadata = lambda: lease_lock.clear_lock_metadata(bot) bot.get_state_issue_html_url = lambda: lease_lock.get_state_issue_html_url(bot) bot.get_lock_ref_display = lambda: lease_lock.get_lock_ref_display(bot) @@ -51,13 +59,8 @@ def _lease_bot(**overrides): return bot -def test_acquire_lock_retries_until_expected_token_visible(monkeypatch): +def test_acquire_lock_retries_until_expected_token_visible(): bot = _lease_bot() - monkeypatch.setattr( - lease_lock, - "get_lock_owner_context", - lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), - ) snapshots = iter( [ ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), @@ -109,13 +112,8 @@ def test_acquire_lock_fails_closed_on_conflicting_visible_token(monkeypatch): lease_lock.acquire_state_issue_lease_lock(bot) -def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypatch): +def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(): bot = _lease_bot() - monkeypatch.setattr( - lease_lock, - "get_lock_owner_context", - lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), - ) snapshots = iter( [ ("old-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), @@ -147,13 +145,8 @@ def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(monkeypa assert bot.ACTIVE_LEASE_CONTEXT is context -def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(monkeypatch): +def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(): bot = _lease_bot() - monkeypatch.setattr( - lease_lock, - "get_lock_owner_context", - lambda current_bot: ("local-run", "reviewer-bot", "reviewer-bot"), - ) snapshots = iter( [ ( @@ -297,20 +290,32 @@ def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(m lease_lock.ensure_lock_ref_exists(bot) -def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(monkeypatch): - bot = _lease_bot( - github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult( - status_code=200, - payload={"message": "missing tree"}, - headers={}, - text="ok", - ok=True, - failure_kind=None, - retry_attempts=0, - transport_error=None, - ) +def test_get_lock_ref_snapshot_fails_closed_on_invalid_commit_payload(): + responses = iter( + [ + GitHubApiResult( + status_code=200, + payload={"object": {"sha": "ref-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + GitHubApiResult( + status_code=200, + payload={"message": "missing tree"}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + ] ) - monkeypatch.setattr(lease_lock, "ensure_lock_ref_exists", lambda current_bot: "ref-sha") + bot = _lease_bot(github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: next(responses)) with pytest.raises(RuntimeError, match="missing tree SHA"): lease_lock.get_lock_ref_snapshot(bot) @@ -358,13 +363,23 @@ def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(m lease_lock.ensure_lock_ref_exists(bot) -def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(monkeypatch): - bot = _lease_bot( - github_api_request=lambda *args, **kwargs: GitHubApiResult( - 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None - ) +def test_get_lock_ref_snapshot_fails_closed_when_commit_fetch_unavailable(): + responses = iter( + [ + GitHubApiResult( + status_code=200, + payload={"object": {"sha": "ref-sha"}}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ), + GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), + ] ) - monkeypatch.setattr(lease_lock, "ensure_lock_ref_exists", lambda current_bot: "ref-sha") + bot = _lease_bot(github_api_request=lambda *args, **kwargs: next(responses)) with pytest.raises(RuntimeError, match="Failed to read lock commit"): lease_lock.get_lock_ref_snapshot(bot) From c67ce4e5bae00bc4ec7329c66fb60a77ebb6ca96 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:44:53 +0900 Subject: [PATCH 161/209] test(reviewer-bot): use focused fakes in board and sweeper tests --- scripts/reviewer_bot_lib/sweeper.py | 12 +++++++++-- tests/unit/reviewer_bot/test_project_board.py | 5 ++++- .../reviewer_bot/test_sweeper_downloads.py | 20 +++++++++++++++---- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index 52a2bcf5b..da39df0ac 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -83,6 +83,14 @@ def uniform(self, lower: float, upper: float) -> float: return retrying.bounded_exponential_delay(base, retry_attempt, jitter=_BotJitter()) +def _sleep(bot: SweeperContext, seconds: float) -> None: + sleeper = getattr(bot, "sleeper", None) + if sleeper is not None and hasattr(sleeper, "sleep"): + sleeper.sleep(seconds) + return + time.sleep(seconds) + + def observer_run_reason_from_details(run_details: dict, runbook_signature: dict | None) -> str: status = str(run_details.get("status", "")).strip() conclusion = run_details.get("conclusion") @@ -321,12 +329,12 @@ def _download_artifact_payload(bot: SweeperContext, artifact: dict, expected_pay ) except Exception: if attempt < max_attempts: - time.sleep(_download_retry_delay(bot, attempt)) + _sleep(bot, _download_retry_delay(bot, attempt)) continue return "download_unavailable", None if retrying.is_retryable_status(response.status_code): if attempt < max_attempts: - time.sleep(_download_retry_delay(bot, attempt)) + _sleep(bot, _download_retry_delay(bot, attempt)) continue return "download_unavailable", None break diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 16d6dd2f3..6579b30e6 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -112,7 +112,10 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) runtime = _runtime(monkeypatch, routes) runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) - monkeypatch.setattr(reviews, "rebuild_pr_approval_state", lambda bot, issue_number, review_data, **kwargs: ({"completed": False}, {"has_write_approval": False})) + runtime.compute_reviewer_response_state = lambda issue_number, review_data, **kwargs: { + "state": "awaiting_reviewer_response", + "anchor_timestamp": "2026-03-21T08:00:00Z", + } preview = project_board.preview_board_projection_for_item(runtime, state, 42) diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index b3de42751..ab1aafbca 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -1,6 +1,7 @@ from scripts.reviewer_bot_lib import sweeper from tests.fixtures.fake_jitter import DeterministicJitter from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.fake_sleeper import RecordingSleeper from tests.fixtures.http_responses import FakeGitHubResponse from tests.fixtures.reviewer_bot import make_zip_payload @@ -28,8 +29,8 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" runtime.jitter = DeterministicJitter(0.5) + runtime.sleeper = RecordingSleeper() runtime.artifact_download_transport = RecordingArtifactDownloadTransport([*responses, success_response]) - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) status, artifact_payload = sweeper._download_artifact_payload( runtime, @@ -39,6 +40,7 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): assert status == "ok" assert artifact_payload == payload + assert runtime.sleeper.calls == [2.5] def test_download_retry_delay_uses_shared_backoff_with_jitter(monkeypatch): @@ -54,8 +56,8 @@ def test_download_artifact_payload_reports_request_exception_unavailable(monkeyp runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" + runtime.sleeper = RecordingSleeper() runtime.artifact_download_transport = RecordingArtifactDownloadTransport([RuntimeError("timeout")]) - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) status, payload = sweeper._download_artifact_payload( runtime, @@ -71,10 +73,10 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" + runtime.sleeper = RecordingSleeper() runtime.artifact_download_transport = RecordingArtifactDownloadTransport( [FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6 ) - monkeypatch.setattr(sweeper.time, "sleep", lambda *_args, **_kwargs: None) status, payload = sweeper._download_artifact_payload( runtime, @@ -84,11 +86,21 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa assert status == "download_unavailable" assert payload is None + assert len(runtime.sleeper.calls) == 5 def test_list_run_artifacts_returns_none_when_api_payload_unavailable(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) - monkeypatch.setattr(sweeper, "_read_api_payload", lambda bot, endpoint: (None, "server_error")) + runtime.github_api_request = lambda method, endpoint, **kwargs: runtime.GitHubApiResult( + status_code=502, + payload={"message": "bad gateway"}, + headers={}, + text="bad gateway", + ok=False, + failure_kind="server_error", + retry_attempts=1, + transport_error=None, + ) assert sweeper._list_run_artifacts(runtime, 42) is None From 903944195fde444f258e292c6230830ec17d6685 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:47:43 +0900 Subject: [PATCH 162/209] refactor(reviewer-bot): add explicit runtime factory entrypoint --- scripts/reviewer_bot.py | 25 +++++++++++++------ .../reviewer_bot/test_adapter_contract.py | 12 +++++++++ .../reviewer_bot/test_cli_entrypoint.py | 20 +++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index f2291dc00..c3ce6a548 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -20,22 +20,33 @@ RUNTIME: ReviewerBotRuntime | None = None -def _runtime_bot() -> ReviewerBotRuntime: +def _runtime_bot(runtime: ReviewerBotRuntime | None = None) -> ReviewerBotRuntime: + if runtime is not None: + return runtime if RUNTIME is None: raise RuntimeError("ReviewerBotRuntime not initialized") return RUNTIME -def build_event_context() -> EventContext: - return build_app_event_context(_runtime_bot()) +def build_event_context(runtime: ReviewerBotRuntime | None = None) -> EventContext: + return build_app_event_context(_runtime_bot(runtime)) -def execute_run(context: EventContext) -> ExecutionResult: - return execute_app_run(_runtime_bot(), context) +def execute_run(context: EventContext, runtime: ReviewerBotRuntime | None = None) -> ExecutionResult: + return execute_app_run(_runtime_bot(runtime), context) -def main() -> None: - run_app_main(_runtime_bot()) +def build_runtime() -> ReviewerBotRuntime: + return _build_runtime() + + +def main( + runtime: ReviewerBotRuntime | None = None, + *, + runtime_factory=build_runtime, +) -> None: + resolved_runtime = runtime or RUNTIME or runtime_factory() + run_app_main(_runtime_bot(resolved_runtime)) def _build_runtime() -> ReviewerBotRuntime: diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index fbf923bab..d0a38d557 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -43,6 +43,18 @@ def test_execute_run_returns_execution_result(monkeypatch): assert result.exit_code == 0 +def test_entrypoint_helpers_accept_explicit_runtime(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("EVENT_NAME", "issue_comment") + runtime.set_config_value("EVENT_ACTION", "created") + + context = reviewer_bot.build_event_context(runtime) + result = reviewer_bot.execute_run(context, runtime) + + assert context.event_name == "issue_comment" + assert result.exit_code == 0 + + def test_review_state_owner_exports_mutation_helper(): hints = get_type_hints(review_state.ensure_review_entry) diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index bd5358b52..db62111ba 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -82,3 +82,23 @@ def test_main_exits_with_nonzero_execution_result(monkeypatch): assert captured.context is not None assert captured.context.event_name == "issue_comment" assert captured.context.event_action == "created" + + +def test_main_accepts_explicit_runtime_argument(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", + ) + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=0, state_changed=False) + ) + + try: + reviewer_bot.main(harness.runtime) + except SystemExit as exc: # pragma: no cover - defensive + pytest.fail(f"unexpected SystemExit: {exc.code}") + + assert captured.context is not None + assert captured.context.event_name == "issue_comment" + assert captured.context.event_action == "created" From 972038b0de6f2ba588e198195f7b84628342c558 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:50:11 +0900 Subject: [PATCH 163/209] test(reviewer-bot): use runtime factories in integration paths --- tests/fixtures/app_harness.py | 5 ++--- tests/integration/reviewer_bot/test_cli_entrypoint.py | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index 73bdda27c..be4329113 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -26,7 +26,6 @@ def __init__(self, monkeypatch): self.locks = self.runtime.locks self.handlers = self.runtime.handlers self.touch_tracker = self.runtime.touch_tracker - self._monkeypatch.setattr(reviewer_bot, "RUNTIME", self.runtime) def set_event(self, **values) -> None: set_env_values(self.config, **values) @@ -66,11 +65,11 @@ def fake_execute_run(bot, context): return captured def run_execute(self): - return reviewer_bot.execute_run(reviewer_bot.build_event_context()) + return reviewer_bot.execute_run(reviewer_bot.build_event_context(self.runtime), self.runtime) def run_main(self) -> MainRun: try: - reviewer_bot.main() + reviewer_bot.main(self.runtime) except SystemExit as exc: code = exc.code if isinstance(exc.code, int) else 1 return MainRun(exit_code=code) diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index db62111ba..e9613f980 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -102,3 +102,9 @@ def test_main_accepts_explicit_runtime_argument(monkeypatch): assert captured.context is not None assert captured.context.event_name == "issue_comment" assert captured.context.event_action == "created" + + +def test_app_harness_no_longer_requires_singleton_runtime_patch(monkeypatch): + harness = AppHarness(monkeypatch) + + assert harness.runtime is not reviewer_bot.RUNTIME From d83153156ee6499e9f35554fc8279af09b887445 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:53:23 +0900 Subject: [PATCH 164/209] refactor(reviewer-bot): remove global runtime singleton --- scripts/reviewer_bot.py | 15 +++------------ .../reviewer_bot/test_cli_entrypoint.py | 4 ++-- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index c3ce6a548..343c66ea3 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -15,17 +15,11 @@ from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime -ACTIVE_LEASE_CONTEXT = None -TOUCHED_ISSUE_NUMBERS: set[int] = set() -RUNTIME: ReviewerBotRuntime | None = None - def _runtime_bot(runtime: ReviewerBotRuntime | None = None) -> ReviewerBotRuntime: if runtime is not None: return runtime - if RUNTIME is None: - raise RuntimeError("ReviewerBotRuntime not initialized") - return RUNTIME + return build_runtime() def build_event_context(runtime: ReviewerBotRuntime | None = None) -> EventContext: @@ -45,7 +39,7 @@ def main( *, runtime_factory=build_runtime, ) -> None: - resolved_runtime = runtime or RUNTIME or runtime_factory() + resolved_runtime = runtime or runtime_factory() run_app_main(_runtime_bot(resolved_runtime)) @@ -55,12 +49,9 @@ def _build_runtime() -> ReviewerBotRuntime: sys=sys, random=random, time=time, - active_lease_context=ACTIVE_LEASE_CONTEXT, + active_lease_context=None, ) -RUNTIME = _build_runtime() - - if __name__ == "__main__": main() diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py index e9613f980..22039912f 100644 --- a/tests/integration/reviewer_bot/test_cli_entrypoint.py +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -105,6 +105,6 @@ def test_main_accepts_explicit_runtime_argument(monkeypatch): def test_app_harness_no_longer_requires_singleton_runtime_patch(monkeypatch): - harness = AppHarness(monkeypatch) + AppHarness(monkeypatch) - assert harness.runtime is not reviewer_bot.RUNTIME + assert hasattr(reviewer_bot, "RUNTIME") is False From b8c4422b8cbe9a4a144bdcf01fa5896ad74f127a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 03:56:25 +0900 Subject: [PATCH 165/209] test(reviewer-bot): align validation fixes with explicit runtime flow --- scripts/reviewer_bot_lib/automation.py | 2 ++ .../contract/reviewer_bot/test_privileged_commands_workflow.py | 1 + .../reviewer_bot/test_reviewer_board_workflow_contracts.py | 2 ++ tests/fixtures/app_harness.py | 1 + 4 files changed, 6 insertions(+) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 2b4786f1c..68ec55531 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -10,6 +10,8 @@ ) from .event_inputs import ( get_target_repo_root as decode_target_repo_root, +) +from .event_inputs import ( parse_issue_labels as decode_issue_labels, ) diff --git a/tests/contract/reviewer_bot/test_privileged_commands_workflow.py b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py index 661995933..428fae62f 100644 --- a/tests/contract/reviewer_bot/test_privileged_commands_workflow.py +++ b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py @@ -1,4 +1,5 @@ from pathlib import Path + import pytest pytestmark = pytest.mark.contract diff --git a/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py index 03bab4f5b..f06f795bf 100644 --- a/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py +++ b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py @@ -1,10 +1,12 @@ from pathlib import Path + import pytest pytestmark = pytest.mark.contract import yaml + def test_sweeper_repair_workflow_exposes_reviewer_board_preview_dispatch(): data = yaml.safe_load(Path(".github/workflows/reviewer-bot-sweeper-repair.yml").read_text(encoding="utf-8")) on_block = data.get("on", data.get(True)) diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index be4329113..9487a3e67 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -26,6 +26,7 @@ def __init__(self, monkeypatch): self.locks = self.runtime.locks self.handlers = self.runtime.handlers self.touch_tracker = self.runtime.touch_tracker + self._monkeypatch.setattr(reviewer_bot, "build_runtime", lambda: self.runtime) def set_event(self, **values) -> None: set_env_values(self.config, **values) From f5fe01ec9e82f9b43856edb9418057fdfa170598 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 04:39:55 +0900 Subject: [PATCH 166/209] test(reviewer-bot): add sequenced shared transport fake behavior --- .../test_fake_github_transport_contract.py | 50 +++++++++++++++++ tests/fixtures/reviewer_bot_fakes.py | 56 ++++++++++++++++--- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_github_transport_contract.py b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py index 498a09fbe..6704d0c9c 100644 --- a/tests/contract/reviewer_bot/test_fake_github_transport_contract.py +++ b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py @@ -97,3 +97,53 @@ def test_route_github_api_add_pull_request_reviews_registers_page_route(): assert result.ok is True assert result.payload == [{"id": 10}] + + +def test_route_github_api_request_sequence_replays_retryable_failures_then_success(): + routes = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [ + github_result(429, {"message": "slow down"}), + github_result(502, {"message": "bad gateway"}), + github_result(200, {"ok": True}), + ], + ) + + first = routes.github_api_request("GET", "issues/42") + second = routes.github_api_request("GET", "issues/42") + third = routes.github_api_request("GET", "issues/42") + fourth = routes.github_api_request("GET", "issues/42") + + assert first.failure_kind == "rate_limited" + assert second.failure_kind == "server_error" + assert third.ok is True + assert fourth.ok is True + + +def test_route_github_api_request_sequence_can_raise_then_return_success(): + routes = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [RuntimeError("timeout"), github_result(200, {"ok": True})], + ) + + with pytest.raises(RuntimeError, match="timeout"): + routes.github_api_request("GET", "issues/42") + + result = routes.github_api_request("GET", "issues/42") + + assert result.ok is True + assert result.payload == {"ok": True} + + +def test_route_github_api_api_sequence_replays_values_and_keeps_last_value(): + routes = RouteGitHubApi().add_api_sequence( + "GET", + "pulls/42", + [{"head": {"sha": "head-1"}}, {"head": {"sha": "head-2"}}], + ) + + assert routes.github_api("GET", "pulls/42") == {"head": {"sha": "head-1"}} + assert routes.github_api("GET", "pulls/42") == {"head": {"sha": "head-2"}} + assert routes.github_api("GET", "pulls/42") == {"head": {"sha": "head-2"}} diff --git a/tests/fixtures/reviewer_bot_fakes.py b/tests/fixtures/reviewer_bot_fakes.py index b79b8f01f..5e680a3ed 100644 --- a/tests/fixtures/reviewer_bot_fakes.py +++ b/tests/fixtures/reviewer_bot_fakes.py @@ -62,12 +62,32 @@ class GitHubCall: class RouteGitHubApi: def __init__(self): - self._request_routes: dict[tuple[str, str], GitHubApiResult] = {} - self._api_routes: dict[tuple[str, str], Any] = {} + self._request_routes: dict[tuple[str, str], list[GitHubApiResult | BaseException]] = {} + self._api_routes: dict[tuple[str, str], list[Any | BaseException]] = {} self._raise_system_exit_on_request = False self.request_calls: list[GitHubCall] = [] self.api_calls: list[GitHubCall] = [] + @staticmethod + def _normalize_route_sequence(value: Any) -> list[Any]: + if isinstance(value, list): + if not value: + raise ValueError("Route sequence cannot be empty") + return list(value) + return [value] + + @staticmethod + def _consume_route(routes: dict[tuple[str, str], list[Any]], key: tuple[str, str], *, kind: str) -> Any: + route = routes.get(key) + if route is None: + raise AssertionError(f"Unexpected GitHub {kind} route: {key[0]} {key[1]}") + current = route[0] + if len(route) > 1: + del route[0] + if isinstance(current, BaseException): + raise current + return current + def add_request( self, method: str, @@ -75,11 +95,31 @@ def add_request( result: GitHubApiResult | None = None, **result_kwargs, ) -> "RouteGitHubApi": - self._request_routes[(method, endpoint)] = result or github_result(**result_kwargs) + return self.add_request_sequence( + method, + endpoint, + [result or github_result(**result_kwargs)], + ) + + def add_request_sequence( + self, + method: str, + endpoint: str, + results: list[GitHubApiResult | BaseException], + ) -> "RouteGitHubApi": + self._request_routes[(method, endpoint)] = self._normalize_route_sequence(results) return self def add_api(self, method: str, endpoint: str, payload: Any) -> "RouteGitHubApi": - self._api_routes[(method, endpoint)] = payload + return self.add_api_sequence(method, endpoint, [payload]) + + def add_api_sequence( + self, + method: str, + endpoint: str, + payloads: list[Any | BaseException], + ) -> "RouteGitHubApi": + self._api_routes[(method, endpoint)] = self._normalize_route_sequence(payloads) return self def add_pull_request_snapshot(self, issue_number: int, payload: Any) -> "RouteGitHubApi": @@ -127,10 +167,7 @@ def github_api_request( ) if self._raise_system_exit_on_request: raise SystemExit(1) - route = self._request_routes.get((method, endpoint)) - if route is None: - raise AssertionError(f"Unexpected GitHub request route: {method} {endpoint}") - return route + return self._consume_route(self._request_routes, (method, endpoint), kind="request") def github_api(self, method: str, endpoint: str, data: dict | None = None): self.api_calls.append( @@ -138,10 +175,11 @@ def github_api(self, method: str, endpoint: str, data: dict | None = None): ) key = (method, endpoint) if key in self._api_routes: - return self._api_routes[key] + return self._consume_route(self._api_routes, key, kind="api") route = self._request_routes.get(key) if route is None: raise AssertionError(f"Unexpected GitHub API route: {method} {endpoint}") + route = self._consume_route(self._request_routes, key, kind="api") if not route.ok: return None if route.payload is None: From fb9e7d4767187169972c2272476ee8baddbe98ce Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 04:47:26 +0900 Subject: [PATCH 167/209] test(reviewer-bot): standardize github api tests on shared transport fakes --- tests/fixtures/focused_fake_services.py | 28 ++- tests/unit/reviewer_bot/test_github_api.py | 231 +++++++++------------ 2 files changed, 125 insertions(+), 134 deletions(-) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index b91533f09..57962e64c 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -107,9 +107,18 @@ def github_api_request(self, method: str, endpoint: str, data=None, extra_header class RestTransportStub: def __init__(self, runtime): self._runtime = runtime + self.calls: list[dict[str, Any]] = [] def request(self, method: str, url: str, *, headers=None, json_data=None, timeout_seconds=None): - del headers, timeout_seconds + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json_data": json_data, + "timeout_seconds": timeout_seconds, + } + ) parsed = urlparse(url) parts = parsed.path.strip("/").split("/") if len(parts) >= 4 and parts[0] == "repos": @@ -123,8 +132,13 @@ def __init__(self, api_result): self.status_code = api_result.status_code or 0 self.headers = api_result.headers self.text = api_result.text - self.content = b"" if api_result.payload is None else json.dumps(api_result.payload).encode("utf-8") self._payload = api_result.payload + if api_result.payload is None: + self.content = b"" + elif isinstance(api_result.payload, Exception): + self.content = b"invalid-json" + else: + self.content = json.dumps(api_result.payload).encode("utf-8") def json(self): if isinstance(self._payload, Exception): @@ -137,11 +151,21 @@ def json(self): class GraphQLTransportStub: def __init__(self): self._query: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) + self.calls: list[dict[str, Any]] = [] def stub(self, func: Callable[..., Any]) -> None: self._query = func def query(self, url: str, *, headers=None, query: str, variables=None, timeout_seconds=None): + self.calls.append( + { + "url": url, + "headers": headers, + "query": query, + "variables": variables, + "timeout_seconds": timeout_seconds, + } + ) return self._query(url=url, headers=headers, query=query, variables=variables, timeout_seconds=timeout_seconds) diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 5b78a9632..7d02a427b 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -1,89 +1,41 @@ -from types import SimpleNamespace - import pytest from scripts.reviewer_bot_lib import automation, github_api from scripts.reviewer_bot_lib.config import LOCK_API_RETRY_LIMIT, GitHubApiResult from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.fake_sleeper import RecordingSleeper from tests.fixtures.http_responses import FakeGitHubResponse from tests.fixtures.recording_logger import RecordingLogger - - -class RecordingRestTransport: - def __init__(self, responses=None): - self._responses = iter(responses or []) - self.calls = [] - - def request(self, method, url, *, headers=None, json_data=None, timeout_seconds=None): - self.calls.append( - { - "method": method, - "url": url, - "headers": headers, - "json_data": json_data, - "timeout_seconds": timeout_seconds, - } - ) - response = next(self._responses) - if isinstance(response, Exception): - raise response - return response - - -class RecordingGraphQLTransport: - def __init__(self, responses=None): - self._responses = iter(responses or []) - self.calls = [] - - def query(self, url, *, headers=None, query=None, variables=None, timeout_seconds=None): - self.calls.append( - { - "url": url, - "headers": headers, - "query": query, - "variables": variables, - "timeout_seconds": timeout_seconds, - } - ) - response = next(self._responses) - if isinstance(response, Exception): - raise response - return response - - -def _bot(*, config=None, **overrides): - config_values = { - "REPO_OWNER": "rustfoundation", - "REPO_NAME": "safety-critical-rust-coding-guidelines", - "GITHUB_TOKEN": "token", - **(config or {}), - } - bot = SimpleNamespace( - GitHubApiResult=GitHubApiResult, - get_github_token=lambda: "token", - get_github_graphql_token=lambda prefer_board_token=False: "token", - get_config_value=lambda name, default="": config_values.get(name, default), - rest_transport=RecordingRestTransport(), - graphql_transport=RecordingGraphQLTransport(), - logger=RecordingLogger(), - sleeper=RecordingSleeper(), - jitter=DeterministicJitter(0.0), - STATE_ISSUE_NUMBER=1, - ) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result + + +def _bot(monkeypatch, *, config=None, github=None, **overrides): + bot = FakeReviewerBotRuntime(monkeypatch, github=github) + bot.logger = RecordingLogger() + bot.sleeper = RecordingSleeper() + bot.jitter = DeterministicJitter(0.0) + bot.set_config_value("REPO_OWNER", "rustfoundation") + bot.set_config_value("REPO_NAME", "safety-critical-rust-coding-guidelines") + bot.set_config_value("GITHUB_TOKEN", "token") + for name, value in (config or {}).items(): + bot.set_config_value(name, value) for key, value in overrides.items(): setattr(bot, key, value) return bot def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): - transport = RecordingRestTransport( + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", [ - FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] + github_result(502, {"message": "bad gateway"}), + github_result(200, {"ok": True}), + ], ) - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") + bot = _bot(monkeypatch, github=github) + result = github_api.github_api_request(bot, "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -92,11 +44,12 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): - transport = RecordingRestTransport( - [github_api.requests.RequestException("timeout"), FakeGitHubResponse(200, {"ok": True}, "ok")] + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [github_api.requests.RequestException("timeout"), github_result(200, {"ok": True})], ) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} @@ -104,38 +57,38 @@ def test_github_api_request_retries_transport_exception_for_idempotent_get(monke def test_github_api_request_classifies_not_found_without_retry(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(404, {"message": "missing"}, "missing")]) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + github = RouteGitHubApi().add_request("GET", "issues/42", result=github_result(404, {"message": "missing"})) + bot = _bot(monkeypatch, github=github) + result = github_api.github_api_request(bot, "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "not_found" assert result.retry_attempts == 0 - assert len(transport.calls) == 1 + assert github.requested_endpoints() == ["issues/42"] def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + github = RouteGitHubApi().add_request("GET", "issues/42", result=github_result(403, {"message": "forbidden"})) + bot = _bot(monkeypatch, github=github) + result = github_api.github_api_request(bot, "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "forbidden" assert result.retry_attempts == 0 - assert len(transport.calls) == 1 + assert github.requested_endpoints() == ["issues/42"] def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - transport = RecordingGraphQLTransport( + responses = iter( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), ] ) - monkeypatch.setattr(github_api.time, "sleep", lambda *_args, **_kwargs: None) - + bot = _bot(monkeypatch) + bot.graphql_transport.stub(lambda **kwargs: next(responses)) result = github_api.github_graphql_request( - _bot(graphql_transport=transport), + bot, "query { viewer { login } }", retry_policy="idempotent_read", ) @@ -146,22 +99,27 @@ def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): - transport = RecordingRestTransport( + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", [ - FakeGitHubResponse(429, {"message": "slow down"}, "slow down"), - FakeGitHubResponse(200, {"ok": True}, "ok"), - ] + github_result(429, {"message": "slow down"}), + github_result(200, {"ok": True}), + ], ) - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read") + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.retry_attempts == 1 def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * (LOCK_API_RETRY_LIMIT + 1)) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [github_result(429, {"message": "slow down"})] * (LOCK_API_RETRY_LIMIT + 1), + ) + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "rate_limited" @@ -169,90 +127,95 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch def test_github_graphql_request_reports_invalid_payload(monkeypatch): - transport = RecordingGraphQLTransport([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) + bot = _bot(monkeypatch) + bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) - result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }") + result = github_api.github_graphql_request(bot, "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_graphql_request_reports_graphql_errors(monkeypatch): - transport = RecordingGraphQLTransport([FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")]) + bot = _bot(monkeypatch) + bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")) - result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }") + result = github_api.github_graphql_request(bot, "query { viewer { login } }") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_api_request_passes_timeout(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(200, {"ok": True}, "ok")]) + github = RouteGitHubApi().add_request("GET", "issues/42", result=github_result(200, {"ok": True})) + bot = _bot(monkeypatch, github=github) - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", timeout_seconds=12.5) + result = github_api.github_api_request(bot, "GET", "issues/42", timeout_seconds=12.5) assert result.ok is True - assert transport.calls[0]["timeout_seconds"] == 12.5 + assert bot.rest_transport.calls[0]["timeout_seconds"] == 12.5 -def test_github_api_request_rejects_idempotent_retry_for_non_get(): +def test_github_api_request_rejects_idempotent_retry_for_non_get(monkeypatch): with pytest.raises(ValueError, match="only valid for REST GET"): - github_api.github_api_request(_bot(), "POST", "issues/42", retry_policy="idempotent_read") + github_api.github_api_request(_bot(monkeypatch), "POST", "issues/42", retry_policy="idempotent_read") -def test_github_graphql_request_rejects_idempotent_retry_for_mutation(): +def test_github_graphql_request_rejects_idempotent_retry_for_mutation(monkeypatch): with pytest.raises(ValueError, match="only valid for GraphQL queries"): github_api.github_graphql_request( - _bot(), + _bot(monkeypatch), "mutation { closeIssue(input: {}) { clientMutationId } }", retry_policy="idempotent_read", ) -def test_remove_label_reports_failure(): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=500, payload={"message": "boom"}, headers={}, text="boom", ok=False, failure_kind="server_error", retry_attempts=0, transport_error=None)) +def test_remove_label_reports_failure(monkeypatch): + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=500, payload={"message": "boom"}, headers={}, text="boom", ok=False, failure_kind="server_error", retry_attempts=0, transport_error=None)) assert github_api.remove_label(bot, 42, "status: awaiting reviewer response") is False -def test_get_user_permission_status_distinguishes_unavailable(): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) +def test_get_user_permission_status_distinguishes_unavailable(monkeypatch): + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) assert github_api.get_user_permission_status(bot, "alice", "triage") == "unavailable" assert github_api.check_user_permission(bot, "alice", "triage") is None def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): - bot = _bot(config={"IS_PULL_REQUEST": "true"}, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None), github_api=lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) + bot = _bot(monkeypatch, config={"IS_PULL_REQUEST": "true"}, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None), github_api=lambda *args, **kwargs: (_ for _ in ()).throw(SystemExit(1))) assert github_api.get_issue_assignees(bot, 42) is None def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=200, payload={"not": "a list"}, headers={}, text="ok", ok=True, failure_kind=None, retry_attempts=0, transport_error=None)) + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=200, payload={"not": "a list"}, headers={}, text="ok", ok=True, failure_kind=None, retry_attempts=0, transport_error=None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_find_open_pr_for_branch_status_reports_unavailable_on_transport_failure(monkeypatch): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=502, payload={"message": "bad gateway"}, headers={}, text="bad gateway", ok=False, failure_kind="server_error", retry_attempts=1, transport_error=None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("unavailable", None) def test_github_api_request_reports_invalid_payload_for_malformed_json(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42") + github = RouteGitHubApi().add_request("GET", "issues/42", result=github_result(200, ValueError("bad json"), text="bad json")) + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42") assert result.ok is False assert result.failure_kind == "invalid_payload" def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway")] * (LOCK_API_RETRY_LIMIT + 1)) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [github_result(502, {"message": "bad gateway"})] * (LOCK_API_RETRY_LIMIT + 1), + ) + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "server_error" @@ -260,9 +223,12 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_502(monkeypatch def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): - transport = RecordingRestTransport([github_api.requests.RequestException("timeout")] * (LOCK_API_RETRY_LIMIT + 1)) - - result = github_api.github_api_request(_bot(rest_transport=transport), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [github_api.requests.RequestException("timeout")] * (LOCK_API_RETRY_LIMIT + 1), + ) + result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read", suppress_error_log=True) assert result.ok is False assert result.failure_kind == "transport_error" @@ -271,8 +237,8 @@ def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch): - transport = RecordingRestTransport([github_api.requests.RequestException("timeout")]) - bot = _bot(rest_transport=transport) + github = RouteGitHubApi().add_request_sequence("GET", "issues/42", [github_api.requests.RequestException("timeout")]) + bot = _bot(monkeypatch, github=github) result = github_api.github_api_request(bot, "GET", "issues/42", suppress_error_log=False) @@ -282,8 +248,8 @@ def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch def test_github_api_request_logs_error_response_to_recording_logger(monkeypatch): - transport = RecordingRestTransport([FakeGitHubResponse(403, {"message": "forbidden"}, "forbidden")]) - bot = _bot(rest_transport=transport) + github = RouteGitHubApi().add_request("GET", "issues/42", result=github_result(403, {"message": "forbidden"})) + bot = _bot(monkeypatch, github=github) result = github_api.github_api_request(bot, "GET", "issues/42", suppress_error_log=False) @@ -293,21 +259,22 @@ def test_github_api_request_logs_error_response_to_recording_logger(monkeypatch) def test_github_graphql_request_passes_timeout(monkeypatch): - transport = RecordingGraphQLTransport([FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")]) + bot = _bot(monkeypatch) + bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")) - result = github_api.github_graphql_request(_bot(graphql_transport=transport), "query { viewer { login } }", timeout_seconds=9.5) + result = github_api.github_graphql_request(bot, "query { viewer { login } }", timeout_seconds=9.5) assert result.ok is True - assert transport.calls[0]["timeout_seconds"] == 9.5 + assert bot.graphql_transport.calls[0]["timeout_seconds"] == 9.5 def test_find_open_pr_for_branch_status_blank_owner_or_branch_is_not_found(monkeypatch): - assert automation.find_open_pr_for_branch_status(_bot(config={"REPO_OWNER": ""}), "feature") == ("not_found", None) - assert automation.find_open_pr_for_branch_status(_bot(), "") == ("not_found", None) + assert automation.find_open_pr_for_branch_status(_bot(monkeypatch, config={"REPO_OWNER": ""}), "feature") == ("not_found", None) + assert automation.find_open_pr_for_branch_status(_bot(monkeypatch), "") == ("not_found", None) def test_find_open_pr_for_branch_status_reports_found(monkeypatch): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [{"number": 42, "html_url": "https://example.com/pr/42"}], {}, "ok", True, None, 0, None)) + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [{"number": 42, "html_url": "https://example.com/pr/42"}], {}, "ok", True, None, 0, None)) status, pr = automation.find_open_pr_for_branch_status(bot, "feature") @@ -316,6 +283,6 @@ def test_find_open_pr_for_branch_status_reports_found(monkeypatch): def test_find_open_pr_for_branch_status_reports_not_found_for_empty_payload(monkeypatch): - bot = _bot(github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [], {}, "ok", True, None, 0, None)) + bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(200, [], {}, "ok", True, None, 0, None)) assert automation.find_open_pr_for_branch_status(bot, "feature") == ("not_found", None) From bdf47792f92d337ece0464b093e6f131bb6b0ca4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 04:50:41 +0900 Subject: [PATCH 168/209] test(reviewer-bot): standardize sweeper download tests on focused transport fakes --- tests/fixtures/focused_fake_services.py | 2 ++ .../reviewer_bot/test_sweeper_downloads.py | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index 57962e64c..cc0923632 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -172,11 +172,13 @@ def query(self, url: str, *, headers=None, query: str, variables=None, timeout_s class ArtifactDownloadTransportStub: def __init__(self): self._download: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) + self.calls: list[dict[str, Any]] = [] def stub(self, func: Callable[..., Any]) -> None: self._download = func def download(self, url: str, *, headers=None, timeout_seconds=None): + self.calls.append({"url": url, "headers": headers, "timeout_seconds": timeout_seconds}) return self._download(url=url, headers=headers, timeout_seconds=timeout_seconds) diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index ab1aafbca..d1437c732 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -6,18 +6,17 @@ from tests.fixtures.reviewer_bot import make_zip_payload -class RecordingArtifactDownloadTransport: - def __init__(self, responses): - self._responses = iter(responses) - self.calls = [] - - def download(self, url, *, headers=None, timeout_seconds=None): - self.calls.append({"url": url, "headers": headers, "timeout_seconds": timeout_seconds}) - response = next(self._responses) +def _stub_download_sequence(runtime, responses): + iterator = iter(responses) + + def fake_download(**kwargs): + response = next(iterator) if isinstance(response, Exception): raise response return response + runtime.artifact_download_transport.stub(fake_download) + def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): payload = {"source_event_key": "issue_comment:100"} @@ -30,7 +29,7 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): runtime.get_github_token = lambda: "token" runtime.jitter = DeterministicJitter(0.5) runtime.sleeper = RecordingSleeper() - runtime.artifact_download_transport = RecordingArtifactDownloadTransport([*responses, success_response]) + _stub_download_sequence(runtime, [*responses, success_response]) status, artifact_payload = sweeper._download_artifact_payload( runtime, @@ -41,6 +40,7 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): assert status == "ok" assert artifact_payload == payload assert runtime.sleeper.calls == [2.5] + assert runtime.artifact_download_transport.calls[0]["url"] == "https://example.com/artifact.zip" def test_download_retry_delay_uses_shared_backoff_with_jitter(monkeypatch): @@ -57,7 +57,7 @@ def test_download_artifact_payload_reports_request_exception_unavailable(monkeyp runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" runtime.sleeper = RecordingSleeper() - runtime.artifact_download_transport = RecordingArtifactDownloadTransport([RuntimeError("timeout")]) + _stub_download_sequence(runtime, [RuntimeError("timeout")]) status, payload = sweeper._download_artifact_payload( runtime, @@ -74,9 +74,7 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" runtime.sleeper = RecordingSleeper() - runtime.artifact_download_transport = RecordingArtifactDownloadTransport( - [FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6 - ) + _stub_download_sequence(runtime, [FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6) status, payload = sweeper._download_artifact_payload( runtime, From c96f772bf72d7635033b803a5b1cc4afaadf356a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 04:55:47 +0900 Subject: [PATCH 169/209] refactor(reviewer-bot): remove ambient env reads from github api assignment helpers --- scripts/reviewer_bot_lib/github_api.py | 13 +++-- .../reviewer_bot/test_adapter_contract.py | 24 ++++++++ tests/unit/reviewer_bot/test_github_api.py | 58 +++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 18c28a458..5c97149df 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -1,7 +1,6 @@ """GitHub transport and issue/PR mutation helpers.""" import json -import os import random import sys import time @@ -52,6 +51,10 @@ def _retry_delay(bot: GitHubApiContext, base_seconds: float, retry_attempt: int) ) +def _is_pull_request(bot: GitHubApiContext) -> bool: + return bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true" + + def _should_retry_status(status_code: int | None) -> bool: return retrying.is_retryable_status(status_code) @@ -473,7 +476,7 @@ def ensure_label_exists( def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, username: str): - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + is_pr = _is_pull_request(bot) if is_pr: endpoint = f"pulls/{issue_number}/requested_reviewers" payload = {"reviewers": [username]} @@ -535,7 +538,7 @@ def assign_reviewer(bot: GitHubTransportContext, issue_number: int, username: st def get_assignment_failure_comment(bot: GitHubTransportContext, reviewer: str, attempt) -> str | None: - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + is_pr = _is_pull_request(bot) if attempt.status_code == 422: if is_pr: return bot.REVIEWER_REQUEST_422_TEMPLATE.format(reviewer=reviewer) @@ -554,7 +557,7 @@ def get_assignment_failure_comment(bot: GitHubTransportContext, reviewer: str, a def get_issue_assignees(bot: GitHubTransportContext, issue_number: int) -> list[str] | None: - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + is_pr = _is_pull_request(bot) if is_pr: try: response = bot.github_api_request("GET", f"pulls/{issue_number}", retry_policy=RETRY_POLICY_IDEMPOTENT_READ) @@ -604,7 +607,7 @@ def remove_pr_reviewer(bot: GitHubTransportContext, issue_number: int, username: def unassign_reviewer(bot: GitHubTransportContext, issue_number: int, username: str) -> bool: - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + is_pr = _is_pull_request(bot) if is_pr: bot.remove_pr_reviewer(issue_number, username) return bot.remove_assignee(issue_number, username) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index d0a38d557..bd5a9a9af 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -164,6 +164,30 @@ def test_event_inputs_build_assignment_and_privileged_requests_from_runtime_conf assert privileged_request.workflow_run_head_sha == "head-1" +def test_github_api_assignment_helpers_use_runtime_config_for_pr_vs_issue(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + recorded = [] + + def fake_request(method, endpoint, data=None, suppress_error_log=True, **kwargs): + recorded.append((method, endpoint, data)) + return runtime.GitHubApiResult(201, {}, {}, "ok", True, None, 0, None) + + runtime.github_api_request = fake_request + + runtime.set_config_value("IS_PULL_REQUEST", "true") + github_pr_attempt = reviewer_bot._runtime_bot(runtime).request_reviewer_assignment(42, "alice") + + runtime.set_config_value("IS_PULL_REQUEST", "false") + github_issue_attempt = reviewer_bot._runtime_bot(runtime).request_reviewer_assignment(42, "alice") + + assert github_pr_attempt.success is True + assert github_issue_attempt.success is True + assert recorded == [ + ("POST", "pulls/42/requested_reviewers", {"reviewers": ["alice"]}), + ("POST", "issues/42/assignees", {"assignees": ["alice"]}), + ] + + def test_event_inputs_parse_labels_and_target_repo_root_from_runtime_config(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("ISSUE_LABELS", '["coding guideline", "fls-audit"]') diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 7d02a427b..94b12f392 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -189,6 +189,64 @@ def test_get_issue_assignees_returns_none_when_fetch_unavailable(monkeypatch): assert github_api.get_issue_assignees(bot, 42) is None +def test_request_reviewer_assignment_uses_runtime_config_for_pr_target(monkeypatch): + recorded = {} + + def fake_request(method, endpoint, data=None, suppress_error_log=True, **kwargs): + recorded.update({"method": method, "endpoint": endpoint, "data": data}) + return GitHubApiResult(201, {}, {}, "ok", True, None, 0, None) + + bot = _bot(monkeypatch, config={"IS_PULL_REQUEST": "true"}, github_api_request=fake_request) + + result = github_api.request_reviewer_assignment(bot, 42, "alice") + + assert result.success is True + assert recorded == { + "method": "POST", + "endpoint": "pulls/42/requested_reviewers", + "data": {"reviewers": ["alice"]}, + } + + +def test_request_reviewer_assignment_uses_runtime_config_for_issue_target(monkeypatch): + recorded = {} + + def fake_request(method, endpoint, data=None, suppress_error_log=True, **kwargs): + recorded.update({"method": method, "endpoint": endpoint, "data": data}) + return GitHubApiResult(201, {}, {}, "ok", True, None, 0, None) + + bot = _bot(monkeypatch, config={"IS_PULL_REQUEST": "false"}, github_api_request=fake_request) + + result = github_api.request_reviewer_assignment(bot, 42, "alice") + + assert result.success is True + assert recorded == { + "method": "POST", + "endpoint": "issues/42/assignees", + "data": {"assignees": ["alice"]}, + } + + +def test_get_assignment_failure_comment_uses_runtime_config_for_pr_message(monkeypatch): + bot = _bot(monkeypatch, config={"IS_PULL_REQUEST": "true"}) + failed = bot.AssignmentAttempt(success=False, status_code=422) + + assert "PR Reviewers" in github_api.get_assignment_failure_comment(bot, "alice", failed) + + +def test_unassign_reviewer_uses_runtime_config_to_remove_pr_reviewer(monkeypatch): + calls = [] + bot = _bot( + monkeypatch, + config={"IS_PULL_REQUEST": "true"}, + remove_pr_reviewer=lambda issue_number, username: calls.append(("pr", issue_number, username)) or True, + remove_assignee=lambda issue_number, username: calls.append(("issue", issue_number, username)) or True, + ) + + assert github_api.unassign_reviewer(bot, 42, "alice") is True + assert calls == [("pr", 42, "alice"), ("issue", 42, "alice")] + + def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): bot = _bot(monkeypatch, github_api_request=lambda *args, **kwargs: GitHubApiResult(status_code=200, payload={"not": "a list"}, headers={}, text="ok", ok=True, failure_kind=None, retry_attempts=0, transport_error=None)) From a1a540168e4d936ccda22d331fa154bccaf8d1bc Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:00:02 +0900 Subject: [PATCH 170/209] refactor(reviewer-bot): make runtime accessors the primary config authority --- scripts/reviewer_bot_lib/config.py | 35 +++++++++------- scripts/reviewer_bot_lib/runtime.py | 40 ++++++++++++------- scripts/reviewer_bot_lib/state_store.py | 20 ++++++++-- .../reviewer_bot/test_adapter_contract.py | 4 ++ 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/scripts/reviewer_bot_lib/config.py b/scripts/reviewer_bot_lib/config.py index 13dab206e..82e93186e 100644 --- a/scripts/reviewer_bot_lib/config.py +++ b/scripts/reviewer_bot_lib/config.py @@ -1,6 +1,5 @@ """Reviewer-bot configuration constants and small shared types.""" -import os from dataclasses import dataclass from typing import Any @@ -9,7 +8,8 @@ CODING_GUIDELINE_LABEL = "coding guideline" FLS_AUDIT_LABEL = "fls-audit" REVIEW_LABELS = {CODING_GUIDELINE_LABEL, FLS_AUDIT_LABEL} -STATE_ISSUE_NUMBER = int(os.environ.get("STATE_ISSUE_NUMBER", "0")) +STATE_ISSUE_NUMBER_ENV = "STATE_ISSUE_NUMBER" +STATE_ISSUE_NUMBER = 0 MEMBERS_URL = ( "https://raw.githubusercontent.com/rustfoundation/" "safety-critical-rust-consortium/main/subcommittee/coding-guidelines/members.md" @@ -22,19 +22,24 @@ LOCK_BLOCK_END_MARKER = "" LOCK_SCHEMA_VERSION = 1 -LOCK_LEASE_TTL_SECONDS = int(os.environ.get("REVIEWER_BOT_LOCK_TTL_SECONDS", "300")) -LOCK_RETRY_BASE_SECONDS = float(os.environ.get("REVIEWER_BOT_LOCK_RETRY_SECONDS", "2")) -LOCK_MAX_WAIT_SECONDS = int(os.environ.get("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", "1200")) -LOCK_API_RETRY_LIMIT = int(os.environ.get("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", "5")) -STATE_READ_RETRY_LIMIT = int(os.environ.get("REVIEWER_BOT_STATE_READ_RETRY_LIMIT", "4")) -STATE_READ_RETRY_BASE_SECONDS = float( - os.environ.get("REVIEWER_BOT_STATE_READ_RETRY_SECONDS", "1") -) -LOCK_RENEWAL_WINDOW_SECONDS = int( - os.environ.get("REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", "60") -) -LOCK_REF_NAME = os.environ.get("REVIEWER_BOT_LOCK_REF_NAME", "heads/reviewer-bot-state-lock") -LOCK_REF_BOOTSTRAP_BRANCH = os.environ.get("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", "main") +LOCK_LEASE_TTL_SECONDS_ENV = "REVIEWER_BOT_LOCK_TTL_SECONDS" +LOCK_RETRY_BASE_SECONDS_ENV = "REVIEWER_BOT_LOCK_RETRY_SECONDS" +LOCK_MAX_WAIT_SECONDS_ENV = "REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS" +LOCK_API_RETRY_LIMIT_ENV = "REVIEWER_BOT_LOCK_API_RETRY_LIMIT" +STATE_READ_RETRY_LIMIT_ENV = "REVIEWER_BOT_STATE_READ_RETRY_LIMIT" +STATE_READ_RETRY_BASE_SECONDS_ENV = "REVIEWER_BOT_STATE_READ_RETRY_SECONDS" +LOCK_RENEWAL_WINDOW_SECONDS_ENV = "REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS" +LOCK_REF_NAME_ENV = "REVIEWER_BOT_LOCK_REF_NAME" +LOCK_REF_BOOTSTRAP_BRANCH_ENV = "REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH" +LOCK_LEASE_TTL_SECONDS = 300 +LOCK_RETRY_BASE_SECONDS = 2.0 +LOCK_MAX_WAIT_SECONDS = 1200 +LOCK_API_RETRY_LIMIT = 5 +STATE_READ_RETRY_LIMIT = 4 +STATE_READ_RETRY_BASE_SECONDS = 1.0 +LOCK_RENEWAL_WINDOW_SECONDS = 60 +LOCK_REF_NAME = "heads/reviewer-bot-state-lock" +LOCK_REF_BOOTSTRAP_BRANCH = "main" LOCK_COMMIT_MARKER = "reviewer-bot-lock-v1" EVENT_INTENT_MUTATING = "mutating" diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index cfdcf2817..8e62fc560 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -21,17 +21,29 @@ EVENT_INTENT_NON_MUTATING_READONLY, FLS_AUDIT_LABEL, LOCK_API_RETRY_LIMIT, + LOCK_API_RETRY_LIMIT_ENV, LOCK_LEASE_TTL_SECONDS, + LOCK_LEASE_TTL_SECONDS_ENV, LOCK_MAX_WAIT_SECONDS, + LOCK_MAX_WAIT_SECONDS_ENV, LOCK_REF_BOOTSTRAP_BRANCH, + LOCK_REF_BOOTSTRAP_BRANCH_ENV, LOCK_REF_NAME, + LOCK_REF_NAME_ENV, LOCK_RENEWAL_WINDOW_SECONDS, + LOCK_RENEWAL_WINDOW_SECONDS_ENV, LOCK_RETRY_BASE_SECONDS, + LOCK_RETRY_BASE_SECONDS_ENV, REVIEW_DEADLINE_DAYS, REVIEW_FRESHNESS_RUNBOOK_PATH, REVIEW_LABELS, REVIEWER_REQUEST_422_TEMPLATE, STATE_ISSUE_NUMBER, + STATE_ISSUE_NUMBER_ENV, + STATE_READ_RETRY_BASE_SECONDS, + STATE_READ_RETRY_BASE_SECONDS_ENV, + STATE_READ_RETRY_LIMIT, + STATE_READ_RETRY_LIMIT_ENV, STATUS_PROJECTION_EPOCH, TRANSITION_PERIOD_DAYS, ) @@ -312,34 +324,34 @@ def set_config_value(self, name: str, value: Any) -> None: self.config.set(name, value) def state_issue_number(self) -> int: - return int(self.get_config_value("STATE_ISSUE_NUMBER", str(STATE_ISSUE_NUMBER or 0)) or 0) + return int(self.get_config_value(STATE_ISSUE_NUMBER_ENV, str(STATE_ISSUE_NUMBER)) or 0) def lock_api_retry_limit(self) -> int: - return int(self.get_config_value("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", str(LOCK_API_RETRY_LIMIT)) or 0) + return int(self.get_config_value(LOCK_API_RETRY_LIMIT_ENV, str(LOCK_API_RETRY_LIMIT)) or 0) def lock_retry_base_seconds(self) -> float: - return float(self.get_config_value("REVIEWER_BOT_LOCK_RETRY_SECONDS", str(LOCK_RETRY_BASE_SECONDS)) or 0.0) + return float(self.get_config_value(LOCK_RETRY_BASE_SECONDS_ENV, str(LOCK_RETRY_BASE_SECONDS)) or 0.0) def lock_max_wait_seconds(self) -> int: - return int(self.get_config_value("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", str(LOCK_MAX_WAIT_SECONDS)) or 0) + return int(self.get_config_value(LOCK_MAX_WAIT_SECONDS_ENV, str(LOCK_MAX_WAIT_SECONDS)) or 0) def lock_lease_ttl_seconds(self) -> int: - return int(self.get_config_value("REVIEWER_BOT_LOCK_TTL_SECONDS", str(LOCK_LEASE_TTL_SECONDS)) or 0) + return int(self.get_config_value(LOCK_LEASE_TTL_SECONDS_ENV, str(LOCK_LEASE_TTL_SECONDS)) or 0) def lock_renewal_window_seconds(self) -> int: - return int( - self.get_config_value( - "REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", - str(LOCK_RENEWAL_WINDOW_SECONDS), - ) - or 0 - ) + return int(self.get_config_value(LOCK_RENEWAL_WINDOW_SECONDS_ENV, str(LOCK_RENEWAL_WINDOW_SECONDS)) or 0) def lock_ref_name(self) -> str: - return self.get_config_value("REVIEWER_BOT_LOCK_REF_NAME", LOCK_REF_NAME) + return self.get_config_value(LOCK_REF_NAME_ENV, LOCK_REF_NAME) def lock_ref_bootstrap_branch(self) -> str: - return self.get_config_value("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", LOCK_REF_BOOTSTRAP_BRANCH) + return self.get_config_value(LOCK_REF_BOOTSTRAP_BRANCH_ENV, LOCK_REF_BOOTSTRAP_BRANCH) + + def state_read_retry_limit(self) -> int: + return int(self.get_config_value(STATE_READ_RETRY_LIMIT_ENV, str(STATE_READ_RETRY_LIMIT)) or 0) + + def state_read_retry_base_seconds(self) -> float: + return float(self.get_config_value(STATE_READ_RETRY_BASE_SECONDS_ENV, str(STATE_READ_RETRY_BASE_SECONDS)) or 0.0) def write_output(self, name: str, value: str) -> None: self.outputs.write(name, value) diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 3e3055e89..749e6c3ee 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -97,13 +97,25 @@ def _lock_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) +def _state_read_retry_limit(bot: StateStoreRuntimeContext) -> int: + accessor = getattr(bot, "state_read_retry_limit", None) + if callable(accessor): + return accessor() + return getattr(bot, "STATE_READ_RETRY_LIMIT", STATE_READ_RETRY_LIMIT) + + +def _state_read_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: + accessor = getattr(bot, "state_read_retry_base_seconds", None) + if callable(accessor): + return accessor() + return getattr(bot, "STATE_READ_RETRY_BASE_SECONDS", STATE_READ_RETRY_BASE_SECONDS) + + def get_state_issue(bot: StateStoreContext) -> dict | None: """Fetch the state issue from GitHub with retry for transient failures.""" state_issue_number = _state_issue_number(bot) - state_read_retry_limit = getattr(bot, "STATE_READ_RETRY_LIMIT", STATE_READ_RETRY_LIMIT) - state_read_retry_base_seconds = getattr( - bot, "STATE_READ_RETRY_BASE_SECONDS", STATE_READ_RETRY_BASE_SECONDS - ) + state_read_retry_limit = _state_read_retry_limit(bot) + state_read_retry_base_seconds = _state_read_retry_base_seconds(bot) if not state_issue_number: _log(bot, "error", "STATE_ISSUE_NUMBER not set") diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index bd5a9a9af..782ff3c14 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -255,6 +255,8 @@ def test_runtime_typed_config_accessors_read_runtime_config(monkeypatch): monkeypatch.setenv("REVIEWER_BOT_LOCK_MAX_WAIT_SECONDS", "180") monkeypatch.setenv("REVIEWER_BOT_LOCK_TTL_SECONDS", "600") monkeypatch.setenv("REVIEWER_BOT_LOCK_RENEWAL_WINDOW_SECONDS", "90") + monkeypatch.setenv("REVIEWER_BOT_STATE_READ_RETRY_LIMIT", "8") + monkeypatch.setenv("REVIEWER_BOT_STATE_READ_RETRY_SECONDS", "1.5") monkeypatch.setenv("REVIEWER_BOT_LOCK_REF_NAME", "heads/test-lock") monkeypatch.setenv("REVIEWER_BOT_LOCK_BOOTSTRAP_BRANCH", "develop") @@ -264,6 +266,8 @@ def test_runtime_typed_config_accessors_read_runtime_config(monkeypatch): assert runtime.lock_max_wait_seconds() == 180 assert runtime.lock_lease_ttl_seconds() == 600 assert runtime.lock_renewal_window_seconds() == 90 + assert runtime.state_read_retry_limit() == 8 + assert runtime.state_read_retry_base_seconds() == 1.5 assert runtime.lock_ref_name() == "heads/test-lock" assert runtime.lock_ref_bootstrap_branch() == "develop" From 83e36dd00acef9bb20324691e33e1ebdcb68d787 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:04:55 +0900 Subject: [PATCH 171/209] refactor(reviewer-bot): require structured logger in core library modules --- scripts/reviewer_bot_lib/comment_routing.py | 7 +------ scripts/reviewer_bot_lib/events.py | 7 +------ scripts/reviewer_bot_lib/github_api.py | 8 +------- scripts/reviewer_bot_lib/lease_lock.py | 8 +------- scripts/reviewer_bot_lib/reconcile.py | 7 +------ scripts/reviewer_bot_lib/state_store.py | 18 +++--------------- tests/unit/reviewer_bot/test_state_store.py | 9 +++++---- 7 files changed, 13 insertions(+), 51 deletions(-) diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index c9db6a29e..e404615c2 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -21,12 +21,7 @@ def _log(bot, level: str, message: str, **fields) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout - print(message, file=stream) + bot.logger.event(level, message, **fields) def _now() -> datetime: diff --git a/scripts/reviewer_bot_lib/events.py b/scripts/reviewer_bot_lib/events.py index 8e9be0eba..b954310ae 100644 --- a/scripts/reviewer_bot_lib/events.py +++ b/scripts/reviewer_bot_lib/events.py @@ -6,12 +6,7 @@ def _log(bot: EventHandlerContext, level: str, message: str, **fields) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout - stream.write(f"{message}\n") + bot.logger.event(level, message, **fields) def _runtime_epoch(state: dict) -> str: diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 5c97149df..a0ea07a0a 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -2,7 +2,6 @@ import json import random -import sys import time from urllib.parse import quote @@ -27,12 +26,7 @@ def uniform(self, lower: float, upper: float) -> float: def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = sys.stderr if level in {"warning", "error"} else sys.stdout - print(message, file=stream) + bot.logger.event(level, message, **fields) def _sleep(bot: GitHubApiContext, seconds: float) -> None: diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index 87cb3b703..d62a81db1 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -1,7 +1,6 @@ """Reviewer-bot lease lock helpers.""" import json -import sys from datetime import datetime, timezone from typing import Any @@ -21,12 +20,7 @@ def _log(bot: LeaseLockRuntimeContext, level: str, message: str, **fields: Any) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = sys.stderr if level in {"warning", "error"} else sys.stdout - stream.write(f"{message}\n") + bot.logger.event(level, message, **fields) def _sleep(bot: LeaseLockRuntimeContext, seconds: float) -> None: diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index eff4d3659..3b1acc5a1 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -25,12 +25,7 @@ def _log(bot, level: str, message: str, **fields) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = __import__("sys").stderr if level in {"warning", "error"} else __import__("sys").stdout - print(message, file=stream) + bot.logger.event(level, message, **fields) def _now_iso(bot) -> str: diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 749e6c3ee..5204d6164 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -2,7 +2,6 @@ import json import re -import sys from datetime import datetime, timezone from typing import Any @@ -30,16 +29,7 @@ def _log(bot: StateStoreRuntimeContext, level: str, message: str, **fields: Any) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - sys.stderr.write(f"{message}\n") - - -def _log_fallback(level: str, message: str) -> None: - del level - sys.stderr.write(f"{message}\n") + bot.logger.event(level, message, **fields) def _sleep(bot: StateStoreRuntimeContext, seconds: float) -> None: @@ -278,8 +268,7 @@ def parse_state_yaml_from_issue_body(body: str) -> dict: try: state = yaml.safe_load(yaml_content) or {} - except yaml.YAMLError as exc: - _log_fallback("warning", f"Failed to parse state YAML: {exc}") + except yaml.YAMLError: state = {} if not isinstance(state, dict): @@ -298,8 +287,7 @@ def parse_lock_metadata_from_issue_body(body: str) -> dict: try: parsed = json.loads(lock_json) - except json.JSONDecodeError as exc: - _log_fallback("warning", f"Failed to parse lock metadata JSON: {exc}") + except json.JSONDecodeError: return normalize_lock_metadata(None) if not isinstance(parsed, dict): diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py index 460241009..a816ba0fe 100644 --- a/tests/unit/reviewer_bot/test_state_store.py +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -15,7 +15,7 @@ def test_load_state_sets_schema_and_epoch_defaults(): - bot = SimpleNamespace(get_state_issue=lambda: {"body": "queue: []\n"}) + bot = SimpleNamespace(get_state_issue=lambda: {"body": "queue: []\n"}, logger=RecordingLogger()) state = state_store.load_state(bot) @@ -39,7 +39,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, get_config_value=lambda name, default="": default) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, get_config_value=lambda name, default="": default, logger=RecordingLogger()) snapshot = state_store.get_state_issue_snapshot(bot) @@ -64,7 +64,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, logger=RecordingLogger()) state_store.conditional_patch_state_issue(bot, "updated", '"etag-1"') @@ -78,7 +78,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed["extra_headers"] = extra_headers return GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request) + bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, logger=RecordingLogger()) state_store.conditional_patch_state_issue(bot, "updated", None) @@ -144,6 +144,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): STATE_ISSUE_NUMBER=1, github_api_request=fake_request, get_config_value=lambda name, default="": config.get(name, default), + logger=RecordingLogger(), ) snapshot = state_store.get_state_issue_snapshot(bot) From ce07d252bee8bba050c9d0fcbb770f0f6d1b7de2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:10:40 +0900 Subject: [PATCH 172/209] refactor(reviewer-bot): replace remaining internal prints in command and review flows --- scripts/reviewer_bot_lib/commands.py | 6 +++++- scripts/reviewer_bot_lib/lifecycle.py | 6 +++++- scripts/reviewer_bot_lib/overdue.py | 8 ++++++-- scripts/reviewer_bot_lib/reviews.py | 8 ++++++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index e1c04a993..87e764a5a 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -16,6 +16,10 @@ ) from .guidance import get_issue_guidance, get_pr_guidance + +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + _CONVERSATIONAL_WORDS = { "i", "we", @@ -387,7 +391,7 @@ def resolve_workflow_run_pr_number( raise RuntimeError(f"Pull request #{pr_number} is missing a valid head SHA") if pull_request_head_sha != reconcile_head_sha: raise RuntimeError(f"Pull request #{pr_number} head SHA does not match workflow_run reconcile context") - print(f"Resolved workflow_run PR from reconcile context: #{pr_number}") + _log(bot, "info", f"Resolved workflow_run PR from reconcile context: #{pr_number}", pr_number=pr_number) return pr_number diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 949c7bd17..409668b98 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -18,6 +18,10 @@ from .reviews import rebuild_pr_approval_state +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + @dataclass(frozen=True) class HeadObservationRepairResult: changed: bool @@ -189,7 +193,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool: from .event_inputs import build_pull_request_sync_request if _runtime_epoch(state) != "freshness_v15": - print("V18 synchronize repair safe-noop before epoch flip") + _log(bot, "info", "V18 synchronize repair safe-noop before epoch flip") return False request = build_pull_request_sync_request(bot) issue_number = request.issue_number diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index 57a6d710c..8c5e83197 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -3,6 +3,10 @@ from __future__ import annotations +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + def check_overdue_reviews(bot, state: dict) -> list[dict]: """Check all active reviews for overdue ones.""" if "active_reviews" not in state: @@ -28,7 +32,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: issue_number = int(issue_key) issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): - print(f"WARNING: Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable") + _log(bot, "warning", f"Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable", issue_number=issue_number) continue if isinstance(issue_snapshot.get("pull_request"), dict): response_state = bot.compute_reviewer_response_state( @@ -175,5 +179,5 @@ def handle_overdue_review_warning(bot, state: dict, issue_number: int, reviewer: now = bot.datetime.now(bot.timezone.utc).isoformat() review_data["transition_warning_sent"] = now - print(f"Posted overdue warning for #{issue_number} to @{reviewer}") + _log(bot, "info", f"Posted overdue warning for #{issue_number} to @{reviewer}", issue_number=issue_number, reviewer=reviewer) return True diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 7a8e9e568..557ef3295 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -22,6 +22,10 @@ ) +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + def _mark_canonical(func): setattr(func, "_reviewer_bot_canonical", True) return func @@ -743,7 +747,7 @@ def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) - review_data["mandatory_approver_label_applied_at"] = now state_changed = True except RuntimeError as exc: - print(f"WARNING: Unable to apply escalation label on #{issue_number}: {exc}", file=bot.sys.stderr) + _log(bot, "warning", f"Unable to apply escalation label on #{issue_number}: {exc}", issue_number=issue_number, error=str(exc)) if review_data.get("mandatory_approver_pinged_at") is None: if bot.post_comment(issue_number, MANDATORY_TRIAGE_ESCALATION_TEMPLATE): review_data["mandatory_approver_pinged_at"] = now @@ -764,7 +768,7 @@ def satisfy_mandatory_approver_requirement(bot, state: dict, issue_number: int, try: bot.remove_label_with_status(issue_number, MANDATORY_TRIAGE_APPROVER_LABEL) except RuntimeError as exc: - print(f"WARNING: Unable to remove escalation label on #{issue_number}: {exc}", file=bot.sys.stderr) + _log(bot, "warning", f"Unable to remove escalation label on #{issue_number}: {exc}", issue_number=issue_number, error=str(exc)) bot.post_comment(issue_number, MANDATORY_TRIAGE_SATISFIED_TEMPLATE.format(approver=approver)) return True From bbac21068ba20f9b6ca5818fd03925b103bc8cf4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:13:56 +0900 Subject: [PATCH 173/209] refactor(reviewer-bot): separate cli output from internal maintenance logging --- scripts/reviewer_bot_lib/maintenance.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 101c36a4a..5473641eb 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -24,6 +24,10 @@ from .sweeper import sweep_deferred_gaps +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + def _now_iso(bot) -> str: return bot.datetime.now(bot.timezone.utc).isoformat() @@ -245,9 +249,13 @@ def handle_scheduled_check(bot, state: dict) -> bool: if _clear_maintenance_repair_marker(review_data, "review_repair"): changed = True except Exception as exc: - print( - f"WARNING: Scheduled repair failed for #{issue_number} during review_repair: {exc}", - file=bot.sys.stderr, + _log( + bot, + "warning", + f"Scheduled repair failed for #{issue_number} during review_repair: {exc}", + issue_number=issue_number, + phase="review_repair", + error=str(exc), ) if _record_maintenance_repair_marker( bot, @@ -262,9 +270,13 @@ def handle_scheduled_check(bot, state: dict) -> bool: try: repair_result = maybe_record_head_observation_repair(bot, issue_number, review_data) except Exception as exc: - print( - f"WARNING: Scheduled repair failed for #{issue_number} during head_observation_repair: {exc}", - file=bot.sys.stderr, + _log( + bot, + "warning", + f"Scheduled repair failed for #{issue_number} during head_observation_repair: {exc}", + issue_number=issue_number, + phase="head_observation_repair", + error=str(exc), ) if _record_maintenance_repair_marker( bot, From 9ee0ba64bdb626cd0a7f635bc2c7451dc02c0d96 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:20:15 +0900 Subject: [PATCH 174/209] refactor(reviewer-bot): replace bootstrap adapter namespace with explicit service --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 391 +++++++++++------- .../reviewer_bot/test_adapter_contract.py | 9 + 2 files changed, 252 insertions(+), 148 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 5d77f8e35..17666ce67 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -2,8 +2,6 @@ from __future__ import annotations -from types import SimpleNamespace - from . import ( automation, commands, @@ -105,6 +103,248 @@ def handle_workflow_run_event(self, current_state): return reconcile.handle_workflow_run_event(self._runtime_getter(), current_state) +class _BootstrapAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + + def assert_lock_held(self, context): + return state_store.assert_lock_held(self._runtime(), context) + + def get_github_token(self): + return github_api.get_github_token(self._runtime()) + + def get_github_graphql_token(self, *, prefer_board_token=False): + return github_api.get_github_graphql_token(self._runtime(), prefer_board_token=prefer_board_token) + + def github_graphql(self, query, variables=None, *, token=None): + return github_api.github_graphql(self._runtime(), query, variables, token=token) + + def post_comment(self, issue_number, body): + return github_api.post_comment(self._runtime(), issue_number, body) + + def get_repo_labels(self): + return github_api.get_repo_labels(self._runtime()) + + def add_label(self, issue_number, label): + return github_api.add_label(self._runtime(), issue_number, label) + + def remove_label(self, issue_number, label): + return github_api.remove_label(self._runtime(), issue_number, label) + + def ensure_label_exists(self, label, *, color=None, description=None): + return github_api.ensure_label_exists(self._runtime(), label, color=color, description=description) + + def get_issue_assignees(self, issue_number): + return github_api.get_issue_assignees(self._runtime(), issue_number) + + def request_reviewer_assignment(self, issue_number, username): + return github_api.request_reviewer_assignment(self._runtime(), issue_number, username) + + def get_assignment_failure_comment(self, reviewer, attempt): + return github_api.get_assignment_failure_comment(self._runtime(), reviewer, attempt) + + def add_reaction(self, comment_id, reaction): + return github_api.add_reaction(self._runtime(), comment_id, reaction) + + def remove_assignee(self, issue_number, username): + return github_api.remove_assignee(self._runtime(), issue_number, username) + + def remove_pr_reviewer(self, issue_number, username): + return github_api.remove_pr_reviewer(self._runtime(), issue_number, username) + + def unassign_reviewer(self, issue_number, username): + return github_api.unassign_reviewer(self._runtime(), issue_number, username) + + def get_user_permission_status(self, username, required_permission="triage"): + return github_api.get_user_permission_status(self._runtime(), username, required_permission) + + def check_user_permission(self, username, required_permission="triage"): + return github_api.check_user_permission(self._runtime(), username, required_permission) + + def get_issue_or_pr_snapshot(self, issue_number): + return github_api.github_api(self._runtime(), "GET", f"issues/{issue_number}") + + def get_pull_request_reviews(self, issue_number): + return reviews.get_pull_request_reviews(self._runtime(), issue_number) + + def maybe_record_head_observation_repair(self, issue_number, review_data): + return lifecycle.maybe_record_head_observation_repair(self._runtime(), issue_number, review_data) + + def handle_transition_notice(self, current_state, issue_number, reviewer): + return lifecycle.handle_transition_notice(self._runtime(), current_state, issue_number, reviewer) + + def handle_pass_command(self, current_state, issue_number, comment_author, reason, request=None): + return commands.handle_pass_command(self._runtime(), current_state, issue_number, comment_author, reason, request=request) + + def handle_pass_until_command(self, current_state, issue_number, comment_author, return_date, reason, request=None): + return commands.handle_pass_until_command(self._runtime(), current_state, issue_number, comment_author, return_date, reason, request=request) + + def handle_label_command(self, current_state, issue_number, label_string, request=None): + return commands.handle_label_command(self._runtime(), current_state, issue_number, label_string, request=request) + + def handle_sync_members_command(self, current_state): + return commands.handle_sync_members_command(self._runtime(), current_state) + + def handle_queue_command(self, current_state): + return commands.handle_queue_command(self._runtime(), current_state) + + def handle_commands_command(self): + return commands.handle_commands_command(self._runtime()) + + def handle_claim_command(self, current_state, issue_number, comment_author, request=None): + return commands.handle_claim_command(self._runtime(), current_state, issue_number, comment_author, request=request) + + def handle_release_command(self, current_state, issue_number, comment_author, args=None, request=None): + return commands.handle_release_command(self._runtime(), current_state, issue_number, comment_author, args, request=request) + + def handle_rectify_command(self, current_state, issue_number, comment_author): + return reconcile.handle_rectify_command(self._runtime(), current_state, issue_number, comment_author) + + def handle_assign_command(self, current_state, issue_number, username, request=None): + return commands.handle_assign_command(self._runtime(), current_state, issue_number, username, request=request) + + def handle_assign_from_queue_command(self, current_state, issue_number, request=None): + return commands.handle_assign_from_queue_command(self._runtime(), current_state, issue_number, request=request) + + def handle_accept_no_fls_changes_command(self, issue_number, comment_author, request=None): + return automation.handle_accept_no_fls_changes_command(self._runtime(), issue_number, comment_author, request=request) + + def get_commands_help(self): + return config.get_commands_help() + + def ensure_review_entry(self, current_state, issue_number, create=False): + return review_state.ensure_review_entry(current_state, issue_number, create=create) + + def set_current_reviewer(self, current_state, issue_number, reviewer, assignment_method="round-robin"): + return review_state.set_current_reviewer(current_state, issue_number, reviewer, assignment_method=assignment_method) + + def update_reviewer_activity(self, current_state, issue_number, reviewer): + return review_state.update_reviewer_activity(current_state, issue_number, reviewer) + + def mark_review_complete(self, current_state, issue_number, reviewer, source): + return review_state.mark_review_complete(current_state, issue_number, reviewer, source) + + def is_triage_or_higher(self, username): + return reviews.is_triage_or_higher(self._runtime(), username) + + def trigger_mandatory_approver_escalation(self, current_state, issue_number): + return reviews.trigger_mandatory_approver_escalation(self._runtime(), current_state, issue_number) + + def satisfy_mandatory_approver_requirement(self, current_state, issue_number, approver): + return reviews.satisfy_mandatory_approver_requirement(self._runtime(), current_state, issue_number, approver) + + def get_next_reviewer(self, state, skip_usernames=None): + return get_next_reviewer(state, skip_usernames) + + def strip_code_blocks(self, comment_body): + return commands.strip_code_blocks(comment_body) + + def parse_command(self, comment_body): + return commands.parse_command(self._runtime(), comment_body) + + def record_assignment(self, state, github, issue_number, kind): + return record_assignment(state, github, issue_number, kind) + + def reposition_member_as_next(self, state, username): + return reposition_member_as_next(state, username) + + def process_pass_until_expirations(self, state): + return process_pass_until_expirations(state) + + def sync_members_with_queue(self, current_state): + return sync_members_with_queue(self._runtime(), current_state) + + def parse_iso8601_timestamp(self, value): + return state_store.parse_iso8601_timestamp(value) + + def compute_reviewer_response_state(self, issue_number, review_data, *, issue_snapshot=None): + return reviews.compute_reviewer_response_state(self._runtime(), issue_number, review_data, issue_snapshot=issue_snapshot) + + def sync_status_labels_for_items(self, current_state, issue_numbers): + return reviews.sync_status_labels_for_items(self._runtime(), current_state, issue_numbers) + + def run_command(self, command, cwd=None, check=True): + return automation.run_command(command, cwd, check) + + def summarize_output(self, result, limit=20): + return automation.summarize_output(result, limit) + + def list_changed_files(self, repo_root): + return automation.list_changed_files(repo_root) + + def get_default_branch(self): + return automation.get_default_branch(self._runtime()) + + def find_open_pr_for_branch_status(self, branch): + return automation.find_open_pr_for_branch_status(self._runtime(), branch) + + def create_pull_request(self, branch, base, issue_number): + return automation.create_pull_request(self._runtime(), branch, base, issue_number) + + def parse_issue_labels(self): + return automation.bot_parse_issue_labels(self._runtime()) + + def normalize_lock_metadata(self, lock_meta): + return state_store.normalize_lock_metadata(lock_meta) + + def get_state_issue(self): + return state_store.get_state_issue(self._runtime()) + + def clear_lock_metadata(self): + return lease_lock.clear_lock_metadata(self._runtime()) + + def get_state_issue_snapshot(self): + return state_store.get_state_issue_snapshot(self._runtime()) + + def conditional_patch_state_issue(self, body, etag=None): + return state_store.conditional_patch_state_issue(self._runtime(), body, etag) + + def parse_lock_metadata_from_issue_body(self, body): + return state_store.parse_lock_metadata_from_issue_body(body) + + def render_state_issue_body(self, current_state, lock_meta, base_body=None, *, preserve_state_block=False): + return state_store.render_state_issue_body(current_state, lock_meta, base_body, preserve_state_block=preserve_state_block) + + def get_state_issue_html_url(self): + return lease_lock.get_state_issue_html_url(self._runtime()) + + def get_lock_ref_display(self): + return lease_lock.get_lock_ref_display(self._runtime()) + + def get_lock_ref_snapshot(self): + return lease_lock.get_lock_ref_snapshot(self._runtime()) + + def build_lock_metadata(self, *args, **kwargs): + return lease_lock.build_lock_metadata(self._runtime(), *args, **kwargs) + + def create_lock_commit(self, parent_sha, tree_sha, lock_meta): + return lease_lock.create_lock_commit(self._runtime(), parent_sha, tree_sha, lock_meta) + + def cas_update_lock_ref(self, new_sha): + return lease_lock.cas_update_lock_ref(self._runtime(), new_sha) + + def lock_is_currently_valid(self, lock_meta, now=None): + return lease_lock.lock_is_currently_valid(self._runtime(), lock_meta, now) + + def renew_state_issue_lease_lock(self, context): + return lease_lock.renew_state_issue_lease_lock(self._runtime(), context) + + def ensure_state_issue_lease_lock_fresh(self): + return lease_lock.ensure_state_issue_lease_lock_fresh(self._runtime()) + + def acquire_state_issue_lease_lock(self): + return lease_lock.acquire_state_issue_lease_lock(self._runtime()) + + def release_state_issue_lease_lock(self): + return lease_lock.release_state_issue_lease_lock(self._runtime()) + + def get_active_lease_context(self): + return self._runtime().ACTIVE_LEASE_CONTEXT + + def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: runtime: ReviewerBotRuntime | None = None @@ -120,152 +360,7 @@ def runtime_getter() -> ReviewerBotRuntime: github_services = _BootstrapGitHubServices(runtime_getter) lock_services = _BootstrapLockServices(runtime_getter) handlers = _BootstrapHandlerServices(runtime_getter) - adapters = SimpleNamespace( - assert_lock_held=lambda context: state_store.assert_lock_held(runtime, context), - get_github_token=lambda: github_api.get_github_token(runtime), - get_github_graphql_token=lambda *, prefer_board_token=False: github_api.get_github_graphql_token( - runtime, prefer_board_token=prefer_board_token - ), - github_graphql=lambda query, variables=None, *, token=None: github_api.github_graphql( - runtime, query, variables, token=token - ), - post_comment=lambda issue_number, body: github_api.post_comment(runtime, issue_number, body), - get_repo_labels=lambda: github_api.get_repo_labels(runtime), - add_label=lambda issue_number, label: github_api.add_label(runtime, issue_number, label), - remove_label=lambda issue_number, label: github_api.remove_label(runtime, issue_number, label), - ensure_label_exists=lambda label, *, color=None, description=None: github_api.ensure_label_exists( - runtime, label, color=color, description=description - ), - get_issue_assignees=lambda issue_number: github_api.get_issue_assignees(runtime, issue_number), - request_reviewer_assignment=lambda issue_number, username: github_api.request_reviewer_assignment( - runtime, issue_number, username - ), - get_assignment_failure_comment=lambda reviewer, attempt: github_api.get_assignment_failure_comment( - runtime, reviewer, attempt - ), - add_reaction=lambda comment_id, reaction: github_api.add_reaction(runtime, comment_id, reaction), - remove_assignee=lambda issue_number, username: github_api.remove_assignee(runtime, issue_number, username), - remove_pr_reviewer=lambda issue_number, username: github_api.remove_pr_reviewer(runtime, issue_number, username), - unassign_reviewer=lambda issue_number, username: github_api.unassign_reviewer(runtime, issue_number, username), - get_user_permission_status=lambda username, required_permission="triage": github_api.get_user_permission_status( - runtime, username, required_permission - ), - check_user_permission=lambda username, required_permission="triage": github_api.check_user_permission( - runtime, username, required_permission - ), - get_issue_or_pr_snapshot=lambda issue_number: github_api.github_api(runtime, "GET", f"issues/{issue_number}"), - get_pull_request_reviews=lambda issue_number: reviews.get_pull_request_reviews(runtime, issue_number), - maybe_record_head_observation_repair=lambda issue_number, review_data: lifecycle.maybe_record_head_observation_repair( - runtime, issue_number, review_data - ), - handle_transition_notice=lambda current_state, issue_number, reviewer: lifecycle.handle_transition_notice( - runtime, current_state, issue_number, reviewer - ), - handle_pass_command=lambda current_state, issue_number, comment_author, reason, request=None: commands.handle_pass_command( - runtime, current_state, issue_number, comment_author, reason, request=request - ), - handle_pass_until_command=lambda current_state, issue_number, comment_author, return_date, reason, request=None: commands.handle_pass_until_command( - runtime, current_state, issue_number, comment_author, return_date, reason, request=request - ), - handle_label_command=lambda current_state, issue_number, label_string, request=None: commands.handle_label_command( - runtime, current_state, issue_number, label_string, request=request - ), - handle_sync_members_command=lambda current_state: commands.handle_sync_members_command( - runtime, current_state - ), - handle_queue_command=lambda current_state: commands.handle_queue_command(runtime, current_state), - handle_commands_command=lambda: commands.handle_commands_command(runtime), - handle_claim_command=lambda current_state, issue_number, comment_author, request=None: commands.handle_claim_command( - runtime, current_state, issue_number, comment_author, request=request - ), - handle_release_command=lambda current_state, issue_number, comment_author, args=None, request=None: commands.handle_release_command( - runtime, current_state, issue_number, comment_author, args, request=request - ), - handle_rectify_command=lambda current_state, issue_number, comment_author: reconcile.handle_rectify_command( - runtime, current_state, issue_number, comment_author - ), - handle_assign_command=lambda current_state, issue_number, username, request=None: commands.handle_assign_command( - runtime, current_state, issue_number, username, request=request - ), - handle_assign_from_queue_command=lambda current_state, issue_number, request=None: commands.handle_assign_from_queue_command( - runtime, current_state, issue_number, request=request - ), - handle_accept_no_fls_changes_command=lambda issue_number, comment_author, request=None: automation.handle_accept_no_fls_changes_command( - runtime, issue_number, comment_author, request=request - ), - get_commands_help=config.get_commands_help, - # Adapter-only mutable review-state compatibility surface. - ensure_review_entry=lambda current_state, issue_number, create=False: review_state.ensure_review_entry( - current_state, issue_number, create=create - ), - set_current_reviewer=lambda current_state, issue_number, reviewer, assignment_method="round-robin": review_state.set_current_reviewer( - current_state, issue_number, reviewer, assignment_method=assignment_method - ), - update_reviewer_activity=lambda current_state, issue_number, reviewer: review_state.update_reviewer_activity( - current_state, issue_number, reviewer - ), - mark_review_complete=lambda current_state, issue_number, reviewer, source: review_state.mark_review_complete( - current_state, issue_number, reviewer, source - ), - is_triage_or_higher=lambda username: reviews.is_triage_or_higher(runtime, username), - trigger_mandatory_approver_escalation=lambda current_state, issue_number: reviews.trigger_mandatory_approver_escalation( - runtime, current_state, issue_number - ), - satisfy_mandatory_approver_requirement=lambda current_state, issue_number, approver: reviews.satisfy_mandatory_approver_requirement( - runtime, current_state, issue_number, approver - ), - get_next_reviewer=get_next_reviewer, - strip_code_blocks=commands.strip_code_blocks, - parse_command=lambda comment_body: commands.parse_command(runtime, comment_body), - record_assignment=record_assignment, - reposition_member_as_next=reposition_member_as_next, - process_pass_until_expirations=process_pass_until_expirations, - sync_members_with_queue=lambda current_state: sync_members_with_queue(runtime, current_state), - parse_iso8601_timestamp=state_store.parse_iso8601_timestamp, - compute_reviewer_response_state=lambda issue_number, review_data, *, issue_snapshot=None: reviews.compute_reviewer_response_state( - runtime, issue_number, review_data, issue_snapshot=issue_snapshot - ), - sync_status_labels_for_items=lambda current_state, issue_numbers: reviews.sync_status_labels_for_items( - runtime, current_state, issue_numbers - ), - run_command=automation.run_command, - summarize_output=automation.summarize_output, - list_changed_files=automation.list_changed_files, - get_default_branch=lambda: automation.get_default_branch(runtime), - find_open_pr_for_branch_status=lambda branch: automation.find_open_pr_for_branch_status(runtime, branch), - create_pull_request=lambda branch, base, issue_number: automation.create_pull_request( - runtime, branch, base, issue_number - ), - parse_issue_labels=lambda: automation.bot_parse_issue_labels(runtime), - normalize_lock_metadata=state_store.normalize_lock_metadata, - get_state_issue=lambda: state_store.get_state_issue(runtime), - clear_lock_metadata=lambda: lease_lock.clear_lock_metadata(runtime), - get_state_issue_snapshot=lambda: state_store.get_state_issue_snapshot(runtime), - conditional_patch_state_issue=lambda body, etag=None: state_store.conditional_patch_state_issue( - runtime, body, etag - ), - parse_lock_metadata_from_issue_body=state_store.parse_lock_metadata_from_issue_body, - render_state_issue_body=lambda current_state, lock_meta, base_body=None, *, preserve_state_block=False: state_store.render_state_issue_body( - current_state, - lock_meta, - base_body, - preserve_state_block=preserve_state_block, - ), - get_state_issue_html_url=lambda: lease_lock.get_state_issue_html_url(runtime), - get_lock_ref_display=lambda: lease_lock.get_lock_ref_display(runtime), - get_lock_ref_snapshot=lambda: lease_lock.get_lock_ref_snapshot(runtime), - build_lock_metadata=lambda *args, **kwargs: lease_lock.build_lock_metadata(runtime, *args, **kwargs), - create_lock_commit=lambda parent_sha, tree_sha, lock_meta: lease_lock.create_lock_commit( - runtime, parent_sha, tree_sha, lock_meta - ), - cas_update_lock_ref=lambda new_sha: lease_lock.cas_update_lock_ref(runtime, new_sha), - lock_is_currently_valid=lambda lock_meta, now=None: lease_lock.lock_is_currently_valid(runtime, lock_meta, now), - renew_state_issue_lease_lock=lambda context: lease_lock.renew_state_issue_lease_lock(runtime, context), - ensure_state_issue_lease_lock_fresh=lambda: lease_lock.ensure_state_issue_lease_lock_fresh(runtime), - acquire_state_issue_lease_lock=lambda: lease_lock.acquire_state_issue_lease_lock(runtime), - release_state_issue_lease_lock=lambda: lease_lock.release_state_issue_lease_lock(runtime), - get_active_lease_context=lambda: runtime.ACTIVE_LEASE_CONTEXT, - ) + adapters = _BootstrapAdapterServices(runtime_getter) runtime = ReviewerBotRuntime( requests=requests, diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 782ff3c14..c9e56b530 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -362,6 +362,15 @@ def test_bootstrap_runtime_wires_explicit_handler_services(): assert hasattr(runtime.handlers, "handle_workflow_run_event") +def test_bootstrap_runtime_wires_explicit_adapter_services(): + runtime = reviewer_bot._runtime_bot() + + assert runtime.adapters.__class__.__name__ == "_BootstrapAdapterServices" + assert hasattr(runtime.adapters, "get_github_token") + assert hasattr(runtime.adapters, "handle_pass_command") + assert hasattr(runtime.adapters, "render_state_issue_body") + + def test_default_stderr_logger_renders_message_and_sorted_fields(): writes = [] logger = StdErrLogger(SimpleNamespace(stderr=SimpleNamespace(write=lambda text: writes.append(text)))) From 6b4607e1b24f6aca37b076dc175d3b2f2b8ffa87 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:29:26 +0900 Subject: [PATCH 175/209] refactor(reviewer-bot): further shrink runtime delegation surface --- scripts/reviewer_bot_lib/app.py | 42 ++++++++++++------------- scripts/reviewer_bot_lib/context.py | 18 ++--------- scripts/reviewer_bot_lib/maintenance.py | 4 +-- scripts/reviewer_bot_lib/runtime.py | 36 --------------------- tests/fixtures/focused_fake_services.py | 5 +++ 5 files changed, 31 insertions(+), 74 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 508cd1aee..09b0918f2 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -27,7 +27,7 @@ def _log(bot: AppExecutionRuntime, level: str, message: str, **fields) -> None: def _revalidate_epoch(bot: AppExecutionRuntime, expected_epoch: str | None, phase: str) -> None: if expected_epoch is None: return - latest_state = bot.load_state(fail_on_unavailable=True) + latest_state = bot.state_store.load_state(fail_on_unavailable=True) latest_epoch = latest_state.get("freshness_runtime_epoch") if latest_epoch != expected_epoch: raise RuntimeError( @@ -180,10 +180,10 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes try: if lock_required: - bot.acquire_state_issue_lease_lock() + bot.locks.acquire() lock_acquired = True - state = bot.load_state(fail_on_unavailable=lock_required) + state = bot.state_store.load_state(fail_on_unavailable=lock_required) active_reviews = state.get("active_reviews") if isinstance(active_reviews, dict): loaded_active_reviews_count = len(active_reviews) @@ -200,42 +200,42 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes if event_name == "issues": if event_action == "opened": - state_changed = bot.handle_issue_or_pr_opened(state) + state_changed = bot.handlers.handle_issue_or_pr_opened(state) elif event_action == "labeled": - state_changed = bot.handle_labeled_event(state) + state_changed = bot.handlers.handle_labeled_event(state) elif event_action == "edited": - state_changed = bot.handle_issue_edited_event(state) + state_changed = bot.handlers.handle_issue_edited_event(state) elif event_action == "closed": - state_changed = bot.handle_closed_event(state) + state_changed = bot.handlers.handle_closed_event(state) elif event_name == "pull_request_target": if event_action == "opened": - state_changed = bot.handle_issue_or_pr_opened(state) + state_changed = bot.handlers.handle_issue_or_pr_opened(state) elif event_action == "labeled": - state_changed = bot.handle_labeled_event(state) + state_changed = bot.handlers.handle_labeled_event(state) elif event_action == "closed": - state_changed = bot.handle_closed_event(state) + state_changed = bot.handlers.handle_closed_event(state) elif event_action == "synchronize": - state_changed = bot.handle_pull_request_target_synchronize(state) + state_changed = bot.handlers.handle_pull_request_target_synchronize(state) elif event_name == "pull_request_review": if event_action in {"submitted", "dismissed"}: - state_changed = bot.handle_pull_request_review_event(state) + state_changed = bot.handlers.handle_pull_request_review_event(state) elif event_name == "issue_comment": if event_action == "created": - state_changed = bot.handle_comment_event(state) + state_changed = bot.handlers.handle_comment_event(state) elif event_name == "workflow_dispatch": - state_changed = bot.handle_manual_dispatch(state) + state_changed = bot.handlers.handle_manual_dispatch(state) elif event_name == "schedule": - state_changed = bot.handle_scheduled_check(state) + state_changed = bot.handlers.handle_scheduled_check(state) elif event_name == "workflow_run": if event_action == "completed": if context.workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: - state_changed = bot.handle_workflow_run_event(state) + state_changed = bot.handlers.handle_workflow_run_event(state) else: _log( bot, @@ -282,14 +282,14 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes _log(bot, "info", "State updates detected; attempting to persist reviewer-bot state.") _revalidate_epoch(bot, loaded_epoch, "authoritative save") - if not bot.save_state(state): + if not bot.state_store.save_state(state): raise RuntimeError( "State updates were computed but could not be persisted. " "Failing this run to avoid silent success." ) if touched_items: - state = bot.load_state(fail_on_unavailable=True) + state = bot.state_store.load_state(fail_on_unavailable=True) if touched_items: if not lock_acquired: @@ -310,7 +310,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes ) if _mark_projection_repair_needed(bot, state, touched_items, str(exc)): _revalidate_epoch(bot, loaded_epoch, "projection-failure repair marker save") - if not bot.save_state(state): + if not bot.state_store.save_state(state): raise RuntimeError( "Projection failed and repair-needed metadata could not be persisted." ) @@ -318,7 +318,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes 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): + if not bot.state_store.save_state(state): raise RuntimeError( "Status projection epoch repair succeeded but could not be persisted." ) @@ -344,7 +344,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes exit_code = 1 finally: if lock_acquired: - if not bot.release_state_issue_lease_lock(): + if not bot.locks.release(): release_failed = True if release_failed: diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 179a5ce32..841668733 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -257,26 +257,14 @@ def get_config_value(self, name: str, default: str = "") -> str: ... class AppExecutionRuntime(AppEventContextRuntime, Protocol): datetime: type[datetime] timezone: Any + state_store: Any + locks: Any + handlers: Any def write_output(self, name: str, value: str) -> None: ... def drain_touched_items(self) -> list[int]: ... - def load_state(self, *, fail_on_unavailable: bool = False) -> dict: ... - def save_state(self, state: dict) -> bool: ... - def ensure_state_issue_lease_lock_fresh(self) -> bool: ... - def acquire_state_issue_lease_lock(self) -> LeaseContext: ... - def release_state_issue_lease_lock(self) -> bool: ... def process_pass_until_expirations(self, state: dict) -> tuple[dict, list[str]]: ... def sync_members_with_queue(self, state: dict) -> tuple[dict, list[str]]: ... - def handle_issue_or_pr_opened(self, state: dict) -> bool: ... - def handle_labeled_event(self, state: dict) -> bool: ... - def handle_issue_edited_event(self, state: dict) -> bool: ... - def handle_closed_event(self, state: dict) -> bool: ... - def handle_pull_request_target_synchronize(self, state: dict) -> bool: ... - def handle_pull_request_review_event(self, state: dict) -> bool: ... - def handle_comment_event(self, state: dict) -> bool: ... - def handle_manual_dispatch(self, state: dict) -> bool: ... - def handle_scheduled_check(self, state: dict) -> bool: ... - def handle_workflow_run_event(self, state: dict) -> bool: ... def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int]) -> bool: ... diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 5473641eb..7dbda3c8f 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -154,14 +154,14 @@ def handle_manual_dispatch(bot, state: dict) -> bool: return False bot.assert_lock_held("handle_manual_dispatch") if action == "sync-members": - _, changes = bot.sync_members_with_queue(state) + _, changes = bot.adapters.sync_members_with_queue(state) return bool(changes) if action == "repair-review-status-labels": for issue_number in bot.list_open_items_with_status_labels(): bot.collect_touched_item(issue_number) return False if action == "check-overdue": - return bot.handle_scheduled_check(state) + return bot.handlers.handle_scheduled_check(state) if action == "execute-pending-privileged-command": source_event_key = request.privileged_source_event_key if not source_event_key: diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 8e62fc560..c85c96f8d 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -359,12 +359,6 @@ def write_output(self, name: str, value: str) -> None: def load_deferred_payload(self) -> dict: return self.deferred_payloads.load() - def load_state(self, *, fail_on_unavailable: bool = False) -> dict: - return self.state_store.load_state(fail_on_unavailable=fail_on_unavailable) - - def save_state(self, state: dict) -> bool: - return self.state_store.save_state(state) - def github_api_request(self, *args, **kwargs): return self.github.github_api_request(*args, **kwargs) @@ -377,36 +371,6 @@ def collect_touched_item(self, issue_number: int | None) -> None: def drain_touched_items(self) -> list[int]: return self.touch_tracker.drain() - def handle_issue_or_pr_opened(self, state: dict) -> bool: - return self.handlers.handle_issue_or_pr_opened(state) - - def handle_labeled_event(self, state: dict) -> bool: - return self.handlers.handle_labeled_event(state) - - def handle_issue_edited_event(self, state: dict) -> bool: - return self.handlers.handle_issue_edited_event(state) - - def handle_closed_event(self, state: dict) -> bool: - return self.handlers.handle_closed_event(state) - - def handle_pull_request_target_synchronize(self, state: dict) -> bool: - return self.handlers.handle_pull_request_target_synchronize(state) - - def handle_pull_request_review_event(self, state: dict) -> bool: - return self.handlers.handle_pull_request_review_event(state) - - def handle_comment_event(self, state: dict) -> bool: - return self.handlers.handle_comment_event(state) - - def handle_manual_dispatch(self, state: dict) -> bool: - return self.handlers.handle_manual_dispatch(state) - - def handle_scheduled_check(self, state: dict) -> bool: - return self.handlers.handle_scheduled_check(state) - - def handle_workflow_run_event(self, state: dict) -> bool: - return self.handlers.handle_workflow_run_event(state) - def assert_lock_held(self, context: str) -> None: return self.adapters.assert_lock_held(context) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index cc0923632..aab67b27f 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -207,6 +207,11 @@ def stub(self, name: str, func: Callable[[dict], bool]) -> None: def call(self, name: str, state: dict) -> bool: return self._handlers[name](state) + def __getattr__(self, name: str): + if name in self.ALLOWED: + return lambda state: self._handlers[name](state) + raise AttributeError(name) + class TouchTrackerStub: def __init__(self): From f92439482ffffe96c2ab4be75861adf5f2c1a745 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:32:57 +0900 Subject: [PATCH 176/209] test(reviewer-bot): reduce fake runtime to mostly delegated compatibility methods --- .../test_fake_runtime_contract.py | 3 ++ tests/fixtures/fake_runtime.py | 46 +++++----------- tests/fixtures/focused_fake_services.py | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index e42a143e6..344e06b1e 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -13,6 +13,7 @@ RestTransportStub, StateStoreStub, TouchTrackerStub, + WorkflowBehaviorStub, ) from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi @@ -213,6 +214,7 @@ def test_focused_fake_service_types_are_exposed_for_direct_fixture_composition(m assert isinstance(runtime.artifact_download_transport, ArtifactDownloadTransportStub) assert isinstance(runtime.handlers, HandlerStub) assert isinstance(runtime.touch_tracker, TouchTrackerStub) + assert isinstance(runtime.workflow, WorkflowBehaviorStub) def test_fake_runtime_groups_focused_services_into_infra_and_domain_shells(monkeypatch): @@ -230,3 +232,4 @@ def test_fake_runtime_groups_focused_services_into_infra_and_domain_shells(monke assert runtime.domain.github is runtime.github assert runtime.domain.locks is runtime.locks assert runtime.domain.handlers is runtime.handlers + assert runtime.domain.workflow is runtime.workflow diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index e938083aa..a236efc41 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -1,10 +1,8 @@ from __future__ import annotations -import json import random import sys import time -from copy import deepcopy from datetime import datetime, timezone from typing import Any, Callable @@ -50,6 +48,7 @@ RestTransportStub, StateStoreStub, TouchTrackerStub, + WorkflowBehaviorStub, ) from tests.fixtures.recording_logger import RecordingLogger @@ -67,11 +66,12 @@ def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport class FakeRuntimeDomainServices: - def __init__(self, *, state_store, github, locks, handlers): + def __init__(self, *, state_store, github, locks, handlers, workflow): self.state_store = state_store self.github = github self.locks = locks self.handlers = handlers + self.workflow = workflow class FakeReviewerBotRuntime: @@ -116,6 +116,7 @@ def __init__(self, monkeypatch, *, github=None): self.state_store = StateStoreStub() self.locks = LockStub() self.github = GitHubStub(github) + self.workflow = WorkflowBehaviorStub() self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -145,10 +146,8 @@ def __init__(self, monkeypatch, *, github=None): github=self.github, locks=self.locks, handlers=self.handlers, + workflow=self.workflow, ) - self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) - self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) - self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False def get_config_value(self, name: str, default: str = "") -> str: return self.config.get(name, default) @@ -211,13 +210,13 @@ def release_state_issue_lease_lock(self) -> bool: return self.locks.release() def process_pass_until_expirations(self, state: dict): - return self._process_pass_until(state) + return self.workflow.process_pass_until_expirations(state) def sync_members_with_queue(self, state: dict): - return self._sync_members(state) + return self.workflow.sync_members_with_queue(state) def sync_status_labels_for_items(self, state: dict, issue_numbers): - return self._sync_status_labels(state, issue_numbers) + return self.workflow.sync_status_labels_for_items(state, issue_numbers) def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): return review_state_module.ensure_review_entry(state, issue_number, create=create) @@ -472,36 +471,19 @@ def stub_deferred_payload(self, payload: dict) -> None: self.deferred_payloads.set_payload(payload) def stub_state_sequence(self, *states: dict) -> None: - state_queue = [deepcopy(state) for state in states] - - def fake_load_state(*, fail_on_unavailable: bool = False): - if not state_queue: - raise AssertionError("No more fake states queued") - if len(state_queue) == 1: - return state_queue[0] - return state_queue.pop(0) - - self.state_store.stub_load(fake_load_state) + self.state_store.stub_state_sequence(*states) def stub_state_unavailable(self, message: str = "state unavailable") -> None: - def fake_load_state(*, fail_on_unavailable: bool = False): - assert fail_on_unavailable is True - raise RuntimeError(message) - - self.state_store.stub_load(fake_load_state) + self.state_store.stub_state_unavailable(message) def record_saves(self, snapshots: list) -> None: - def fake_save_state(state: dict) -> bool: - snapshots.append(json.loads(json.dumps(state))) - return True - - self.state_store.stub_save(fake_save_state) + self.state_store.record_saves(snapshots) def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: - self._process_pass_until = func + self.workflow.stub_pass_until(func) def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: - self._sync_members = func + self.workflow.stub_sync_members(func) def stub_sync_status_labels(self, func: Callable[[dict, Any], bool]) -> None: - self._sync_status_labels = func + self.workflow.stub_sync_status_labels(func) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index aab67b27f..fc046ace1 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from copy import deepcopy from typing import Any, Callable from urllib.parse import urlparse @@ -49,6 +50,33 @@ def stub_load(self, func: Callable[..., dict]) -> None: def stub_save(self, func: Callable[[dict], bool]) -> None: self._save = func + def stub_state_sequence(self, *states: dict) -> None: + state_queue = [deepcopy(state) for state in states] + + def fake_load_state(*, fail_on_unavailable: bool = False): + del fail_on_unavailable + if not state_queue: + raise AssertionError("No more fake states queued") + if len(state_queue) == 1: + return state_queue[0] + return state_queue.pop(0) + + self.stub_load(fake_load_state) + + def stub_state_unavailable(self, message: str = "state unavailable") -> None: + def fake_load_state(*, fail_on_unavailable: bool = False): + assert fail_on_unavailable is True + raise RuntimeError(message) + + self.stub_load(fake_load_state) + + def record_saves(self, snapshots: list) -> None: + def fake_save_state(state: dict) -> bool: + snapshots.append(json.loads(json.dumps(state))) + return True + + self.stub_save(fake_save_state) + def load_state(self, *, fail_on_unavailable: bool = False) -> dict: return self._load(fail_on_unavailable=fail_on_unavailable) @@ -225,3 +253,28 @@ def drain(self) -> list[int]: items = list(self._touched) self._touched.clear() return items + + +class WorkflowBehaviorStub: + def __init__(self): + self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) + self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) + self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False + + def process_pass_until_expirations(self, state: dict): + return self._process_pass_until(state) + + def sync_members_with_queue(self, state: dict): + return self._sync_members(state) + + def sync_status_labels_for_items(self, state: dict, issue_numbers): + return self._sync_status_labels(state, issue_numbers) + + def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self._process_pass_until = func + + def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self._sync_members = func + + def stub_sync_status_labels(self, func: Callable[[dict, Any], bool]) -> None: + self._sync_status_labels = func From 3a7aa914adab41c0163af09004274879444a8198 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:37:32 +0900 Subject: [PATCH 177/209] test(reviewer-bot): remove runtime factory patching from app harness --- tests/fixtures/app_harness.py | 1 - .../reviewer_bot/test_app_schedule_repairs.py | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/fixtures/app_harness.py b/tests/fixtures/app_harness.py index 9487a3e67..be4329113 100644 --- a/tests/fixtures/app_harness.py +++ b/tests/fixtures/app_harness.py @@ -26,7 +26,6 @@ def __init__(self, monkeypatch): self.locks = self.runtime.locks self.handlers = self.runtime.handlers self.touch_tracker = self.runtime.touch_tracker - self._monkeypatch.setattr(reviewer_bot, "build_runtime", lambda: self.runtime) def set_event(self, **values) -> None: set_env_values(self.config, **values) diff --git a/tests/integration/reviewer_bot/test_app_schedule_repairs.py b/tests/integration/reviewer_bot/test_app_schedule_repairs.py index a44a2f77e..b97a6cabc 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_repairs.py +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -4,7 +4,6 @@ pytestmark = pytest.mark.integration -from scripts import reviewer_bot from scripts.reviewer_bot_lib import maintenance, review_state from scripts.reviewer_bot_lib.config import STATUS_PROJECTION_EPOCH from tests.fixtures.app_harness import AppHarness @@ -31,7 +30,7 @@ def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_re harness.stub_sync_status_labels(lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True) harness.stub_save_state(lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert synced_issue_numbers == [42, 99] @@ -56,7 +55,7 @@ def test_execute_run_schedule_status_projection_epoch_not_advanced_on_label_sync harness.stub_sync_status_labels(lambda current, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection exploded"))) harness.stub_save_state(lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert all(epoch != STATUS_PROJECTION_EPOCH for epoch in saved_epochs) @@ -100,7 +99,7 @@ def fake_save_state(updated_state): output_path = tmp_path / "github-output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert state["active_reviews"]["42"]["repair_needed"]["kind"] == "projection_failure" @@ -143,8 +142,8 @@ def fake_sweep(bot, current): ) harness.stub_sync_status_labels(lambda current, issue_numbers: False) - first = reviewer_bot.execute_run(reviewer_bot.build_event_context()) - second = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + first = harness.run_execute() + second = harness.run_execute() assert first.exit_code == 0 assert second.exit_code == 0 From 8f91b1db19e98f11f413d9920f6d4f5cb2d0b97b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:41:31 +0900 Subject: [PATCH 178/209] test(reviewer-bot): remove final sleep patch from github api tests --- tests/unit/reviewer_bot/test_github_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index 94b12f392..a09b80e47 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -41,6 +41,7 @@ def test_github_api_request_retries_idempotent_get_on_502(monkeypatch): assert result.payload == {"ok": True} assert result.retry_attempts == 1 assert result.failure_kind is None + assert bot.sleeper.calls == [2.0] def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): @@ -49,11 +50,13 @@ def test_github_api_request_retries_transport_exception_for_idempotent_get(monke "issues/42", [github_api.requests.RequestException("timeout"), github_result(200, {"ok": True})], ) - result = github_api.github_api_request(_bot(monkeypatch, github=github), "GET", "issues/42", retry_policy="idempotent_read") + bot = _bot(monkeypatch, github=github) + result = github_api.github_api_request(bot, "GET", "issues/42", retry_policy="idempotent_read") assert result.ok is True assert result.payload == {"ok": True} assert result.retry_attempts == 1 + assert bot.sleeper.calls == [2.0] def test_github_api_request_classifies_not_found_without_retry(monkeypatch): From 7f329548e5caba18eb011f9c099713b72d57f0a0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:48:20 +0900 Subject: [PATCH 179/209] test(reviewer-bot): reduce ad hoc runtime shells in state and maintenance tests --- tests/unit/reviewer_bot/test_state_store.py | 62 ++++++++++----------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/tests/unit/reviewer_bot/test_state_store.py b/tests/unit/reviewer_bot/test_state_store.py index a816ba0fe..41f456b07 100644 --- a/tests/unit/reviewer_bot/test_state_store.py +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -1,5 +1,3 @@ -from types import SimpleNamespace - from scripts.reviewer_bot_lib import state_store from scripts.reviewer_bot_lib.config import ( FRESHNESS_RUNTIME_EPOCH_LEGACY, @@ -9,13 +7,21 @@ ) from tests.fixtures.fake_clock import FakeClock from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime from tests.fixtures.fake_sleeper import RecordingSleeper from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot import make_state -def test_load_state_sets_schema_and_epoch_defaults(): - bot = SimpleNamespace(get_state_issue=lambda: {"body": "queue: []\n"}, logger=RecordingLogger()) +def _bot(monkeypatch, **overrides): + bot = FakeReviewerBotRuntime(monkeypatch) + for key, value in overrides.items(): + setattr(bot, key, value) + return bot + + +def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): + bot = _bot(monkeypatch, get_state_issue=lambda: {"body": "queue: []\n"}) state = state_store.load_state(bot) @@ -23,7 +29,7 @@ def test_load_state_sets_schema_and_epoch_defaults(): assert state["freshness_runtime_epoch"] == FRESHNESS_RUNTIME_EPOCH_LEGACY -def test_get_state_issue_snapshot_uses_retry_aware_read(): +def test_get_state_issue_snapshot_uses_retry_aware_read(monkeypatch): observed = {} def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): @@ -39,7 +45,9 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, get_config_value=lambda name, default="": default, logger=RecordingLogger()) + bot = _bot(monkeypatch, github_api_request=fake_request) + bot.state_issue_number = lambda: 1 + bot.get_config_value = lambda name, default="": default snapshot = state_store.get_state_issue_snapshot(bot) @@ -48,7 +56,7 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): assert observed["retry_policy"] == "idempotent_read" -def test_conditional_patch_state_issue_sends_if_match_header(): +def test_conditional_patch_state_issue_sends_if_match_header(monkeypatch): observed = {} def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): @@ -64,28 +72,28 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): transport_error=None, ) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, logger=RecordingLogger()) + bot = _bot(monkeypatch, STATE_ISSUE_NUMBER=1, github_api_request=fake_request) state_store.conditional_patch_state_issue(bot, "updated", '"etag-1"') assert observed["extra_headers"] == {"If-Match": '"etag-1"'} -def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(): +def test_conditional_patch_state_issue_omits_if_match_when_etag_missing(monkeypatch): observed = {} def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): observed["extra_headers"] = extra_headers return GitHubApiResult(200, {"body": data["body"]}, {}, "ok", True, None, 0, None) - bot = SimpleNamespace(STATE_ISSUE_NUMBER=1, github_api_request=fake_request, logger=RecordingLogger()) + bot = _bot(monkeypatch, STATE_ISSUE_NUMBER=1, github_api_request=fake_request) state_store.conditional_patch_state_issue(bot, "updated", None) assert observed["extra_headers"] is None -def test_save_state_retries_precondition_failed_conflict_uses_injected_time_services(): +def test_save_state_retries_precondition_failed_conflict_uses_injected_time_services(monkeypatch): state = make_state() snapshot = StateIssueSnapshot( body="body", @@ -104,19 +112,14 @@ def test_save_state_retries_precondition_failed_conflict_uses_injected_time_serv jitter = DeterministicJitter(0.25) logger = RecordingLogger() - bot = SimpleNamespace( - STATE_ISSUE_NUMBER=1, - ACTIVE_LEASE_CONTEXT=object(), - clock=clock, - sleeper=sleeper, - jitter=jitter, - logger=logger, - ensure_state_issue_lease_lock_fresh=lambda: True, - get_state_issue_snapshot=lambda: snapshot, - parse_lock_metadata_from_issue_body=lambda body: {}, - render_state_issue_body=lambda state_obj, lock_meta, base_body: "updated", - conditional_patch_state_issue=lambda body, etag=None: next(responses), - ) + bot = _bot(monkeypatch, clock=clock, sleeper=sleeper, jitter=jitter, logger=logger) + bot.set_config_value("STATE_ISSUE_NUMBER", 1) + bot.ACTIVE_LEASE_CONTEXT = object() + bot.locks.stub(refresh=lambda: True) + bot.get_state_issue_snapshot = lambda: snapshot + bot.parse_lock_metadata_from_issue_body = lambda body: {} + bot.render_state_issue_body = lambda state_obj, lock_meta, base_body: "updated" + bot.conditional_patch_state_issue = lambda body, etag=None: next(responses) assert state_store.save_state(bot, state) is True assert state["last_updated"] == clock.now().isoformat() @@ -126,7 +129,7 @@ def test_save_state_retries_precondition_failed_conflict_uses_injected_time_serv assert logger.records[-1]["level"] == "info" -def test_get_state_issue_snapshot_builds_html_url_from_runtime_config_when_missing(): +def test_get_state_issue_snapshot_builds_html_url_from_runtime_config_when_missing(monkeypatch): def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): return GitHubApiResult( status_code=200, @@ -140,12 +143,9 @@ def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): ) config = {"REPO_OWNER": "rustfoundation", "REPO_NAME": "safety-critical-rust-coding-guidelines"} - bot = SimpleNamespace( - STATE_ISSUE_NUMBER=1, - github_api_request=fake_request, - get_config_value=lambda name, default="": config.get(name, default), - logger=RecordingLogger(), - ) + bot = _bot(monkeypatch, github_api_request=fake_request) + bot.state_issue_number = lambda: 1 + bot.get_config_value = lambda name, default="": config.get(name, default) snapshot = state_store.get_state_issue_snapshot(bot) From 7c2b4f84baf345704491e9fba113f8210440cf3f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:56:58 +0900 Subject: [PATCH 180/209] refactor(reviewer-bot): inject transport and logging into members helper --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 4 ++ scripts/reviewer_bot_lib/members.py | 17 ++++--- scripts/reviewer_bot_lib/runtime.py | 5 ++ .../reviewer_bot/test_runtime_protocols.py | 1 + tests/fixtures/fake_runtime.py | 7 +++ tests/unit/reviewer_bot/test_members.py | 49 +++++++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 tests/unit/reviewer_bot/test_members.py diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 17666ce67..7720c1901 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -12,6 +12,7 @@ lease_lock, lifecycle, maintenance, + members, reconcile, review_state, reviews, @@ -257,6 +258,9 @@ def process_pass_until_expirations(self, state): def sync_members_with_queue(self, current_state): return sync_members_with_queue(self._runtime(), current_state) + def fetch_members(self): + return members.fetch_members(self._runtime()) + def parse_iso8601_timestamp(self, value): return state_store.parse_iso8601_timestamp(value) diff --git a/scripts/reviewer_bot_lib/members.py b/scripts/reviewer_bot_lib/members.py index d9f0e61bf..2502b4ae2 100644 --- a/scripts/reviewer_bot_lib/members.py +++ b/scripts/reviewer_bot_lib/members.py @@ -1,20 +1,21 @@ """Reviewer queue membership helpers.""" -import sys +from .config import MEMBERS_URL -import requests -from .config import MEMBERS_URL +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) -def fetch_members() -> list[dict]: +def fetch_members(bot) -> list[dict]: """Fetch and parse members.md from the consortium repo to extract Producers.""" try: - response = requests.get(MEMBERS_URL, timeout=10) - response.raise_for_status() + response = bot.rest_transport.request("GET", MEMBERS_URL, timeout_seconds=10) + if getattr(response, "status_code", 0) >= 400: + raise RuntimeError(f"status {response.status_code}") content = response.text - except requests.RequestException as exc: - print(f"WARNING: Failed to fetch members file from {MEMBERS_URL}: {exc}", file=sys.stderr) + except Exception as exc: + _log(bot, "warning", f"Failed to fetch members file from {MEMBERS_URL}: {exc}", url=MEMBERS_URL, error=str(exc)) return [] producers = [] diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index c85c96f8d..4d1cef55e 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -630,3 +630,8 @@ def sync_members_with_queue(self, state: dict): def sync_status_labels_for_items(self, state: dict, issue_numbers): return self.adapters.sync_status_labels_for_items(state, issue_numbers) + + def fetch_members(self): + from . import members as members_module + + return members_module.fetch_members(self) diff --git a/tests/contract/reviewer_bot/test_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py index 8a2b4087c..813462e38 100644 --- a/tests/contract/reviewer_bot/test_runtime_protocols.py +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -23,6 +23,7 @@ def _assert_core_runtime_surface(runtime) -> None: assert hasattr(runtime, "graphql_transport") assert hasattr(runtime, "artifact_download_transport") assert hasattr(runtime, "logger") + assert hasattr(runtime, "fetch_members") assert hasattr(runtime, "state_issue_number") assert hasattr(runtime, "lock_api_retry_limit") assert hasattr(runtime, "lock_retry_base_seconds") diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index a236efc41..7bf867a23 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -117,6 +117,7 @@ def __init__(self, monkeypatch, *, github=None): self.locks = LockStub() self.github = GitHubStub(github) self.workflow = WorkflowBehaviorStub() + self._fetch_members = lambda: [] self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -398,6 +399,9 @@ def get_commands_help(self) -> str: def parse_issue_labels(self) -> list[str]: return automation_module.bot_parse_issue_labels(self) + def fetch_members(self): + return self._fetch_members() + def run_command(self, command, cwd, check=False): return automation_module.run_command(command, cwd=cwd, check=check) @@ -487,3 +491,6 @@ def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> N def stub_sync_status_labels(self, func: Callable[[dict, Any], bool]) -> None: self.workflow.stub_sync_status_labels(func) + + def stub_fetch_members(self, func: Callable[[], list[dict]]) -> None: + self._fetch_members = func diff --git a/tests/unit/reviewer_bot/test_members.py b/tests/unit/reviewer_bot/test_members.py new file mode 100644 index 000000000..6210de00c --- /dev/null +++ b/tests/unit/reviewer_bot/test_members.py @@ -0,0 +1,49 @@ +from scripts.reviewer_bot_lib import members, queue +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime + + +class TextResponse: + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + + +def test_fetch_members_parses_producers_from_members_table(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.rest_transport.stub( + lambda **kwargs: TextResponse( + 200, + """ +| Member Name | Role | GitHub Username | +| --- | --- | --- | +| Alice Example | Producer | @alice | +| Bob Example | Observer | @bob | +| Carol Example | Producer | carol | +""", + ) + ) + + assert members.fetch_members(runtime) == [ + {"github": "alice", "name": "Alice Example"}, + {"github": "carol", "name": "Carol Example"}, + ] + + +def test_fetch_members_logs_warning_and_returns_empty_list_on_failure(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.rest_transport.stub(lambda **kwargs: (_ for _ in ()).throw(RuntimeError("timeout"))) + + assert members.fetch_members(runtime) == [] + assert runtime.logger.records[-1]["level"] == "warning" + assert "Failed to fetch members file" in runtime.logger.records[-1]["message"] + + +def test_queue_sync_members_with_queue_uses_runtime_fetch_members(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.stub_fetch_members(lambda: [{"github": "alice", "name": "Alice Example"}]) + state = {"queue": [], "pass_until": [], "current_index": 0} + + updated, changes = queue.sync_members_with_queue(runtime, state) + + assert updated["queue"] == [{"github": "alice", "name": "Alice Example"}] + assert changes == ["Added alice to queue"] From e812608b087bb21bdd094bbe5d07ce7e7d9ee8ac Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 05:57:51 +0900 Subject: [PATCH 181/209] test(reviewer-bot): support direct-url focused transport stubs --- tests/fixtures/focused_fake_services.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index fc046ace1..e94da80be 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -136,6 +136,10 @@ class RestTransportStub: def __init__(self, runtime): self._runtime = runtime self.calls: list[dict[str, Any]] = [] + self._direct_request: Callable[..., Any] | None = None + + def stub(self, func: Callable[..., Any]) -> None: + self._direct_request = func def request(self, method: str, url: str, *, headers=None, json_data=None, timeout_seconds=None): self.calls.append( @@ -149,6 +153,14 @@ def request(self, method: str, url: str, *, headers=None, json_data=None, timeou ) parsed = urlparse(url) parts = parsed.path.strip("/").split("/") + if self._direct_request is not None and (len(parts) < 4 or parts[0] != "repos"): + return self._direct_request( + method=method, + url=url, + headers=headers, + json_data=json_data, + timeout_seconds=timeout_seconds, + ) if len(parts) >= 4 and parts[0] == "repos": endpoint = "/".join(parts[3:]) else: From ce7f89430745ea8b4bd84649afa2d071e730b1f3 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 06:13:01 +0900 Subject: [PATCH 182/209] test(reviewer-bot): align bookkeeping tests with explicit runtime flow --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 1 + tests/contract/reviewer_bot/test_adapter_contract.py | 2 +- .../reviewer_bot/test_app_schedule_bookkeeping.py | 5 ++--- .../reviewer_bot/test_app_workflow_run_bookkeeping.py | 7 +++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 7720c1901..81c8588bb 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -216,6 +216,7 @@ def handle_accept_no_fls_changes_command(self, issue_number, comment_author, req def get_commands_help(self): return config.get_commands_help() + # Adapter-only mutable review-state compatibility surface. def ensure_review_entry(self, current_state, issue_number, create=False): return review_state.ensure_review_entry(current_state, issue_number, create=create) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index c9e56b530..a9da2c51e 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -38,7 +38,7 @@ def test_execute_run_returns_execution_result(monkeypatch): monkeypatch.setattr(runtime.state_store, "load_state", lambda *, fail_on_unavailable=False: {"active_reviews": {}}) monkeypatch.setattr(runtime.handlers, "handle_pull_request_review_event", lambda state: False) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = reviewer_bot.execute_run(reviewer_bot.build_event_context(runtime), runtime) assert result.exit_code == 0 diff --git a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py index 61a17cbd2..6f268fb29 100644 --- a/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -1,6 +1,5 @@ import pytest -from scripts import reviewer_bot from scripts.reviewer_bot_lib import lifecycle, maintenance, review_state from scripts.reviewer_bot_lib.config import GitHubApiResult from tests.fixtures.app_harness import AppHarness @@ -43,7 +42,7 @@ def fake_sweep(bot, current): harness.stub_save_state(lambda current: save_calls.append(list(current["active_reviews"]["42"]["reconciled_source_events"])) or True) harness.stub_sync_status_labels(lambda current, issue_numbers: True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_calls == [["pull_request_review:500"]] @@ -134,7 +133,7 @@ def fake_github_api_request(method, endpoint, data=None, extra_headers=None, **k ) harness.stub_sync_status_labels(lambda current, issue_numbers: True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_calls == [ diff --git a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py index 93e53d479..acf170cad 100644 --- a/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -4,7 +4,6 @@ pytestmark = pytest.mark.integration -from scripts import reviewer_bot from scripts.reviewer_bot_lib import comment_routing, review_state from tests.fixtures.app_harness import AppHarness from tests.fixtures.reviewer_bot import make_state @@ -80,7 +79,7 @@ def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(t ) harness.stub_sync_status_labels(lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_snapshots == [{"reconciled": ["pull_request_review:11"], "gap_present": False}] @@ -158,7 +157,7 @@ def test_execute_run_workflow_run_deferred_comment_bookkeeping_only_reconcile_st ) harness.stub_sync_status_labels(lambda current, issue_numbers: True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_snapshots == [{"reconciled": ["issue_comment:210"], "gap_present": False}] @@ -237,7 +236,7 @@ def test_execute_run_workflow_run_deferred_review_comment_bookkeeping_only_recon ) harness.stub_sync_status_labels(lambda current, issue_numbers: True) - result = reviewer_bot.execute_run(reviewer_bot.build_event_context()) + result = harness.run_execute() assert result.exit_code == 0 assert save_snapshots == [ From 5dc4e1399f455d8c81af0ae096fdee4c8a90127a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 08:18:10 +0900 Subject: [PATCH 183/209] test(reviewer-bot): realign architecture contracts with authority boundaries --- .../reviewer_bot/test_adapter_contract.py | 14 ++++------ .../test_fake_runtime_contract.py | 27 +++---------------- .../test_review_state_contract.py | 4 --- .../reviewer_bot/test_runtime_protocols.py | 27 ++++++++----------- 4 files changed, 20 insertions(+), 52 deletions(-) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index a9da2c51e..355eaa255 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -69,7 +69,7 @@ def test_runtime_head_repair_contract_is_runtime_scoped(): def test_runtime_review_state_adapter_mutates_active_reviews(): state = make_state() - review = reviewer_bot._runtime_bot().ensure_review_entry(state, 42, create=True) + review = reviewer_bot._runtime_bot().domain.adapters.ensure_review_entry(state, 42, create=True) assert review is state["active_reviews"]["42"] @@ -337,26 +337,23 @@ def test_bootstrap_runtime_wires_explicit_config_output_and_deferred_services(): assert runtime.config is runtime.infra.config assert runtime.outputs is runtime.infra.outputs assert runtime.deferred_payloads is runtime.infra.deferred_payloads - assert runtime.config.__class__.__name__ == "_EnvConfig" - assert runtime.outputs.__class__.__name__ == "_FileOutputSink" - assert runtime.deferred_payloads.__class__.__name__ == "_JsonDeferredPayloadLoader" def test_bootstrap_runtime_wires_explicit_state_github_and_lock_services(): runtime = reviewer_bot._runtime_bot() - assert runtime.state_store.__class__.__name__ == "_BootstrapStateStoreServices" - assert runtime.github.__class__.__name__ == "_BootstrapGitHubServices" - assert runtime.locks.__class__.__name__ == "_BootstrapLockServices" assert hasattr(runtime.locks, "acquire") assert hasattr(runtime.locks, "release") assert hasattr(runtime.locks, "refresh") + assert hasattr(runtime.state_store, "load_state") + assert hasattr(runtime.state_store, "save_state") + assert hasattr(runtime.github, "github_api") + assert hasattr(runtime.github, "github_api_request") def test_bootstrap_runtime_wires_explicit_handler_services(): runtime = reviewer_bot._runtime_bot() - assert runtime.handlers.__class__.__name__ == "_BootstrapHandlerServices" assert hasattr(runtime.handlers, "handle_issue_or_pr_opened") assert hasattr(runtime.handlers, "handle_comment_event") assert hasattr(runtime.handlers, "handle_workflow_run_event") @@ -365,7 +362,6 @@ def test_bootstrap_runtime_wires_explicit_handler_services(): def test_bootstrap_runtime_wires_explicit_adapter_services(): runtime = reviewer_bot._runtime_bot() - assert runtime.adapters.__class__.__name__ == "_BootstrapAdapterServices" assert hasattr(runtime.adapters, "get_github_token") assert hasattr(runtime.adapters, "handle_pass_command") assert hasattr(runtime.adapters, "render_state_issue_body") diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 344e06b1e..49d49c159 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -20,7 +20,7 @@ pytestmark = pytest.mark.contract -def test_fake_runtime_config_writes_mirror_env(monkeypatch): +def test_fake_runtime_config_writes_round_trip_locally(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) runtime.set_config_value("EVENT_NAME", "issue_comment") @@ -145,30 +145,11 @@ def test_fake_runtime_uses_explicit_public_service_fields(monkeypatch): assert runtime.touch_tracker is not None -def test_fake_runtime_mutable_review_state_gateways_delegate_to_review_state_owner(monkeypatch): +def test_fake_runtime_review_state_compatibility_surface_is_limited(monkeypatch): runtime = FakeReviewerBotRuntime(monkeypatch) - assert runtime.ensure_review_entry.__func__.__module__ == FakeReviewerBotRuntime.__module__ - assert runtime.set_current_reviewer.__func__.__module__ == FakeReviewerBotRuntime.__module__ - assert runtime.update_reviewer_activity.__func__.__module__ == FakeReviewerBotRuntime.__module__ - assert runtime.mark_review_complete.__func__.__module__ == FakeReviewerBotRuntime.__module__ - - -def test_fake_runtime_closed_mutable_review_state_compatibility_surface(monkeypatch): - runtime = FakeReviewerBotRuntime(monkeypatch) - - allowed = { - "ensure_review_entry", - "set_current_reviewer", - "update_reviewer_activity", - "mark_review_complete", - } - removed = { - "record_transition_notice_sent", - "accept_channel_event", - "record_reviewer_activity", - "get_current_cycle_boundary", - } + allowed = {"ensure_review_entry", "set_current_reviewer", "update_reviewer_activity", "mark_review_complete"} + removed = {"record_transition_notice_sent", "accept_channel_event", "record_reviewer_activity", "get_current_cycle_boundary"} for name in allowed: assert hasattr(runtime, name) diff --git a/tests/contract/reviewer_bot/test_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py index 9bfb1df6f..05bd55bc4 100644 --- a/tests/contract/reviewer_bot/test_review_state_contract.py +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -119,10 +119,6 @@ def test_runtime_and_bootstrap_forwarders_are_explicit_adapter_compatibility_sur assert "Adapter-only mutable review-state compatibility surface." in runtime_text assert "Adapter-only mutable review-state compatibility surface." in bootstrap_text - assert "review_state.ensure_review_entry(" in bootstrap_text - assert "review_state.set_current_reviewer(" in bootstrap_text - assert "review_state.update_reviewer_activity(" in bootstrap_text - assert "review_state.mark_review_complete(" in bootstrap_text assert "record_transition_notice_sent" not in runtime_text assert "accept_channel_event" not in runtime_text assert "get_current_cycle_boundary" not in runtime_text diff --git a/tests/contract/reviewer_bot/test_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py index 813462e38..b44e622a6 100644 --- a/tests/contract/reviewer_bot/test_runtime_protocols.py +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -17,22 +17,17 @@ def _assert_core_runtime_surface(runtime) -> None: assert hasattr(runtime, "get_config_value") - assert hasattr(runtime, "get_github_token") - assert hasattr(runtime, "get_github_graphql_token") - assert hasattr(runtime, "rest_transport") - assert hasattr(runtime, "graphql_transport") - assert hasattr(runtime, "artifact_download_transport") - assert hasattr(runtime, "logger") - assert hasattr(runtime, "fetch_members") - assert hasattr(runtime, "state_issue_number") - assert hasattr(runtime, "lock_api_retry_limit") - assert hasattr(runtime, "lock_retry_base_seconds") - assert hasattr(runtime, "lock_lease_ttl_seconds") - assert hasattr(runtime, "lock_max_wait_seconds") - assert hasattr(runtime, "lock_renewal_window_seconds") - assert hasattr(runtime, "lock_ref_name") - assert hasattr(runtime, "lock_ref_bootstrap_branch") - assert hasattr(runtime, "get_pull_request_reviews") + assert hasattr(runtime, "infra") + assert hasattr(runtime, "domain") + assert runtime.infra.config is runtime.config + assert runtime.infra.logger is runtime.logger + assert runtime.infra.rest_transport is runtime.rest_transport + assert runtime.infra.graphql_transport is runtime.graphql_transport + assert runtime.infra.artifact_download_transport is runtime.artifact_download_transport + assert runtime.domain.state_store is runtime.state_store + assert runtime.domain.github is runtime.github + assert runtime.domain.locks is runtime.locks + assert runtime.domain.handlers is runtime.handlers def test_runtime_bot_returns_concrete_runtime_object(): From c6fcb640a2a6fef6b24f75b3eb4d416b21cc26c4 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 08:38:07 +0900 Subject: [PATCH 184/209] refactor(reviewer-bot): retire broad runtime delegation surface --- scripts/reviewer_bot_lib/app.py | 6 +- .../reviewer_bot_lib/comment_application.py | 36 +++++--- scripts/reviewer_bot_lib/context.py | 4 +- scripts/reviewer_bot_lib/maintenance.py | 7 +- scripts/reviewer_bot_lib/runtime.py | 87 ------------------- tests/fixtures/fake_runtime.py | 19 +++- 6 files changed, 49 insertions(+), 110 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 09b0918f2..40b2f395c 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -190,11 +190,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes loaded_epoch = state.get("freshness_runtime_epoch") if isinstance(state.get("freshness_runtime_epoch"), str) else None if lock_required: - state, restored = bot.process_pass_until_expirations(state) + state, restored = bot.adapters.process_pass_until_expirations(state) if restored: _log(bot, "info", f"Restored from pass-until: {restored}", restored=restored) - state, sync_changes = bot.sync_members_with_queue(state) + state, sync_changes = bot.adapters.sync_members_with_queue(state) if sync_changes: _log(bot, "info", f"Members sync changes: {sync_changes}", sync_changes=sync_changes) @@ -299,7 +299,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes ) _revalidate_epoch(bot, loaded_epoch, "status-label projection") try: - status_labels_changed = bot.sync_status_labels_for_items(state, touched_items) + status_labels_changed = bot.adapters.sync_status_labels_for_items(state, touched_items) except RuntimeError as exc: projection_failure = exc _log( diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py index 896454888..a08c3df5e 100644 --- a/scripts/reviewer_bot_lib/comment_application.py +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -5,6 +5,9 @@ import hashlib from datetime import datetime, timezone +from . import commands as commands_module +from . import config as config_module +from . import reconcile as reconcile_module from .context import AssignmentRequest, CommentEventRequest from .review_state import ( accept_channel_event, @@ -99,7 +102,7 @@ def validate_accept_no_fls_changes_handoff( ) -> tuple[bool, dict]: if request.is_pull_request: return False, {"reason": "pull_request_target_not_allowed"} - labels = bot.parse_issue_labels() + labels = commands_module.parse_issue_labels(bot) if bot.FLS_AUDIT_LABEL not in labels: return False, {"reason": "missing_fls_audit_label"} permission_status = bot.get_user_permission_status(request.comment_author, "triage") @@ -163,7 +166,8 @@ def apply_comment_command( state_changed = False assignment_request = build_assignment_request_from_comment(request) if command == "pass": - response, success = bot.handle_pass_command( + response, success = commands_module.handle_pass_command( + bot, state, issue_number, comment_author, @@ -173,7 +177,8 @@ def apply_comment_command( state_changed = success elif command == "away": if args: - response, success = bot.handle_pass_until_command( + response, success = commands_module.handle_pass_until_command( + bot, state, issue_number, comment_author, @@ -185,21 +190,23 @@ def apply_comment_command( else: response = f"❌ Missing date. Usage: `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]`" elif command == "label": - response, success, state_changed = bot.handle_label_command( + response, success, state_changed = commands_module.handle_label_command( + bot, state, issue_number, " ".join(args), request=assignment_request, ) elif command == "sync-members": - response, success = bot.handle_sync_members_command(state) + response, success = commands_module.handle_sync_members_command(bot, state) state_changed = success elif command == "queue": - response, success = bot.handle_queue_command(state) + response, success = commands_module.handle_queue_command(bot, state) elif command == "commands": - response, success = bot.handle_commands_command() + response, success = commands_module.handle_commands_command(bot) elif command == "claim": - response, success = bot.handle_claim_command( + response, success = commands_module.handle_claim_command( + bot, state, issue_number, comment_author, @@ -207,7 +214,8 @@ def apply_comment_command( ) state_changed = success elif command == "release": - response, success = bot.handle_release_command( + response, success = commands_module.handle_release_command( + bot, state, issue_number, comment_author, @@ -216,9 +224,10 @@ def apply_comment_command( ) state_changed = success elif command == "rectify": - response, success, state_changed = bot.handle_rectify_command(state, issue_number, comment_author) + response, success, state_changed = reconcile_module.handle_rectify_command(bot, state, issue_number, comment_author) elif command == "r?-user": - response, success = bot.handle_assign_command( + response, success = commands_module.handle_assign_command( + bot, state, issue_number, args[0] if args else "", @@ -226,7 +235,8 @@ def apply_comment_command( ) state_changed = success elif command == "assign-from-queue": - response, success = bot.handle_assign_from_queue_command( + response, success = commands_module.handle_assign_from_queue_command( + bot, state, issue_number, request=assignment_request, @@ -241,7 +251,7 @@ def apply_comment_command( attempted = args[0] if args else "" response = f"⚠️ Unknown command `{attempted}`. Commands require a `/` prefix.\n\nTry `{bot.BOT_MENTION} /commands` to see available commands." else: - response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{bot.get_commands_help()}" + response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{config_module.get_commands_help()}" comment_id = request.comment_id if comment_id > 0 and command != "_multiple_commands": bot.add_reaction(comment_id, "eyes") diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 841668733..575d1a3b1 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -260,12 +260,10 @@ class AppExecutionRuntime(AppEventContextRuntime, Protocol): state_store: Any locks: Any handlers: Any + adapters: Any def write_output(self, name: str, value: str) -> None: ... def drain_touched_items(self) -> list[int]: ... - def process_pass_until_expirations(self, state: dict) -> tuple[dict, list[str]]: ... - def sync_members_with_queue(self, state: dict) -> tuple[dict, list[str]]: ... - def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int]) -> bool: ... @runtime_checkable diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 7dbda3c8f..ae1097366 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -4,9 +4,10 @@ import yaml +from . import automation from .context import PrivilegedCommandRequest from .event_inputs import build_manual_dispatch_request -from .lifecycle import maybe_record_head_observation_repair +from .lifecycle import handle_transition_notice, maybe_record_head_observation_repair from .overdue import ( backfill_transition_notice_if_present, check_overdue_reviews, @@ -221,7 +222,7 @@ def handle_manual_dispatch(bot, state: dict) -> bool: command_name=str(command_name), labels=labels, ) - message, success = bot.handle_accept_no_fls_changes_command(issue_number, actor, request=request) + message, success = automation.handle_accept_no_fls_changes_command(bot, issue_number, actor, request=request) record["completed_at"] = _now_iso(bot) record["result_message"] = message record["status"] = "executed" if success else "failed_closed" @@ -314,6 +315,6 @@ def handle_scheduled_check(bot, state: dict) -> bool: elif review["needs_transition"]: if backfill_transition_notice_if_present(bot, state, issue_number): changed = True - elif bot.handle_transition_notice(state, issue_number, reviewer): + elif handle_transition_notice(bot, state, issue_number, reviewer): changed = True return changed diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 4d1cef55e..cea32060c 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -434,87 +434,9 @@ def get_pull_request_reviews(self, issue_number: int): def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): return self.adapters.maybe_record_head_observation_repair(issue_number, review_data) - def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: - return self.adapters.handle_transition_notice(state, issue_number, reviewer) - - def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request=None): - return self.adapters.handle_pass_command(state, issue_number, comment_author, reason, request=request) - - def handle_pass_until_command( - self, - state: dict, - issue_number: int, - comment_author: str, - return_date: str, - reason: str | None, - request=None, - ): - return self.adapters.handle_pass_until_command( - state, - issue_number, - comment_author, - return_date, - reason, - request=request, - ) - - def handle_label_command(self, state: dict, issue_number: int, label_string: str, request=None): - return self.adapters.handle_label_command(state, issue_number, label_string, request=request) - - def handle_sync_members_command(self, state: dict): - return self.adapters.handle_sync_members_command(state) - - def handle_queue_command(self, state: dict): - return self.adapters.handle_queue_command(state) - - def handle_commands_command(self): - return self.adapters.handle_commands_command() - - def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request=None): - return self.adapters.handle_claim_command(state, issue_number, comment_author, request=request) - - def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request=None): - return self.adapters.handle_release_command(state, issue_number, comment_author, args, request=request) - - def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): - return self.adapters.handle_rectify_command(state, issue_number, comment_author) - - def handle_assign_command(self, state: dict, issue_number: int, username: str, request=None): - return self.adapters.handle_assign_command(state, issue_number, username, request=request) - - def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): - return self.adapters.handle_assign_from_queue_command(state, issue_number, request=request) - - def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): - return self.adapters.handle_accept_no_fls_changes_command(issue_number, comment_author, request=request) - - def get_commands_help(self) -> str: - return self.adapters.get_commands_help() - # Adapter-only mutable review-state compatibility surface. # Ownership lives in review_state.py; these methods remain so runtime-oriented # callers and test doubles can delegate through one adapter seam. - def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): - return self.adapters.ensure_review_entry(state, issue_number, create=create) - - def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str = "round-robin") -> None: - return self.adapters.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) - - def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: - return self.adapters.update_reviewer_activity(state, issue_number, reviewer) - - def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: - return self.adapters.mark_review_complete(state, issue_number, reviewer, source) - - def is_triage_or_higher(self, username: str) -> bool: - return self.adapters.is_triage_or_higher(username) - - def trigger_mandatory_approver_escalation(self, state: dict, issue_number: int) -> bool: - return self.adapters.trigger_mandatory_approver_escalation(state, issue_number) - - def satisfy_mandatory_approver_requirement(self, state: dict, issue_number: int, approver: str) -> bool: - return self.adapters.satisfy_mandatory_approver_requirement(state, issue_number, approver) - def get_next_reviewer(self, state: dict, skip_usernames=None): return self.adapters.get_next_reviewer(state, skip_usernames) @@ -622,15 +544,6 @@ def release_state_issue_lease_lock(self) -> bool: self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() return result - def process_pass_until_expirations(self, state: dict): - return self.adapters.process_pass_until_expirations(state) - - def sync_members_with_queue(self, state: dict): - return self.adapters.sync_members_with_queue(state) - - def sync_status_labels_for_items(self, state: dict, issue_numbers): - return self.adapters.sync_status_labels_for_items(state, issue_numbers) - def fetch_members(self): from . import members as members_module diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 7bf867a23..b247d8e36 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -66,12 +66,27 @@ def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport class FakeRuntimeDomainServices: - def __init__(self, *, state_store, github, locks, handlers, workflow): + def __init__(self, *, state_store, github, locks, handlers, workflow, adapters): self.state_store = state_store self.github = github self.locks = locks self.handlers = handlers self.workflow = workflow + self.adapters = adapters + + +class FakeRuntimeAdapterServices: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def process_pass_until_expirations(self, state: dict): + return self._runtime.workflow.process_pass_until_expirations(state) + + def sync_members_with_queue(self, state: dict): + return self._runtime.workflow.sync_members_with_queue(state) + + def sync_status_labels_for_items(self, state: dict, issue_numbers): + return self._runtime.workflow.sync_status_labels_for_items(state, issue_numbers) class FakeReviewerBotRuntime: @@ -118,6 +133,7 @@ def __init__(self, monkeypatch, *, github=None): self.github = GitHubStub(github) self.workflow = WorkflowBehaviorStub() self._fetch_members = lambda: [] + self.adapters = FakeRuntimeAdapterServices(self) self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -148,6 +164,7 @@ def __init__(self, monkeypatch, *, github=None): locks=self.locks, handlers=self.handlers, workflow=self.workflow, + adapters=self.adapters, ) def get_config_value(self, name: str, default: str = "") -> str: From 3c03b3dfe42126a0a1542460ab4a77440826cb0a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 08:40:07 +0900 Subject: [PATCH 185/209] refactor(reviewer-bot): align command flows with slimmer runtime surface --- scripts/reviewer_bot_lib/commands.py | 2 +- scripts/reviewer_bot_lib/comment_application.py | 3 ++- tests/unit/reviewer_bot/test_commands.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 87e764a5a..78562ad97 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -396,7 +396,7 @@ def resolve_workflow_run_pr_number( def handle_sync_members_command(bot, state: dict) -> tuple[str, bool]: - state, changes = bot.sync_members_with_queue(state) + state, changes = bot.adapters.sync_members_with_queue(state) if changes: changes_text = "\n".join(f"- {change}" for change in changes) return f"✅ Queue synced with members.md:\n\n{changes_text}", True diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py index a08c3df5e..60f290793 100644 --- a/scripts/reviewer_bot_lib/comment_application.py +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -7,7 +7,6 @@ from . import commands as commands_module from . import config as config_module -from . import reconcile as reconcile_module from .context import AssignmentRequest, CommentEventRequest from .review_state import ( accept_channel_event, @@ -224,6 +223,8 @@ def apply_comment_command( ) state_changed = success elif command == "rectify": + from . import reconcile as reconcile_module + response, success, state_changed = reconcile_module.handle_rectify_command(bot, state, issue_number, comment_author) elif command == "r?-user": response, success = commands_module.handle_assign_command( diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index d92deea08..ea5b8a301 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -298,7 +298,7 @@ def test_handle_rectify_command_reports_permission_denied(monkeypatch): def test_validate_accept_no_fls_changes_handoff_distinguishes_permission_unavailable(monkeypatch): harness = CommentRoutingHarness(monkeypatch) - harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.set_config_value("ISSUE_LABELS", f'["{FLS_AUDIT_LABEL}"]') harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" request = harness.request( issue_number=42, From 72e6dc5115c14455e1e13e529edd7ef97d3e904e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 08:56:18 +0900 Subject: [PATCH 186/209] refactor(reviewer-bot): split bootstrap adapter wiring by responsibility --- scripts/reviewer_bot_lib/app.py | 6 +- scripts/reviewer_bot_lib/bootstrap_runtime.py | 63 ++++++++-- scripts/reviewer_bot_lib/commands.py | 2 +- scripts/reviewer_bot_lib/maintenance.py | 2 +- scripts/reviewer_bot_lib/runtime.py | 115 +++++++++--------- .../reviewer_bot/test_adapter_contract.py | 14 ++- tests/fixtures/fake_runtime.py | 1 + 7 files changed, 126 insertions(+), 77 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 40b2f395c..02bcfe72c 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -190,11 +190,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes loaded_epoch = state.get("freshness_runtime_epoch") if isinstance(state.get("freshness_runtime_epoch"), str) else None if lock_required: - state, restored = bot.adapters.process_pass_until_expirations(state) + state, restored = bot.adapters.workflow.process_pass_until_expirations(state) if restored: _log(bot, "info", f"Restored from pass-until: {restored}", restored=restored) - state, sync_changes = bot.adapters.sync_members_with_queue(state) + state, sync_changes = bot.adapters.workflow.sync_members_with_queue(state) if sync_changes: _log(bot, "info", f"Members sync changes: {sync_changes}", sync_changes=sync_changes) @@ -299,7 +299,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes ) _revalidate_epoch(bot, loaded_epoch, "status-label projection") try: - status_labels_changed = bot.adapters.sync_status_labels_for_items(state, touched_items) + status_labels_changed = bot.adapters.workflow.sync_status_labels_for_items(state, touched_items) except RuntimeError as exc: projection_failure = exc _log( diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 81c8588bb..37277bf92 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -104,7 +104,7 @@ def handle_workflow_run_event(self, current_state): return reconcile.handle_workflow_run_event(self._runtime_getter(), current_state) -class _BootstrapAdapterServices: +class _BootstrapGitHubAdapterServices: def __init__(self, runtime_getter): self._runtime_getter = runtime_getter @@ -171,6 +171,14 @@ def get_issue_or_pr_snapshot(self, issue_number): def get_pull_request_reviews(self, issue_number): return reviews.get_pull_request_reviews(self._runtime(), issue_number) + +class _BootstrapReviewAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + def maybe_record_head_observation_repair(self, issue_number, review_data): return lifecycle.maybe_record_head_observation_repair(self._runtime(), issue_number, review_data) @@ -253,23 +261,36 @@ def record_assignment(self, state, github, issue_number, kind): def reposition_member_as_next(self, state, username): return reposition_member_as_next(state, username) + def compute_reviewer_response_state(self, issue_number, review_data, *, issue_snapshot=None): + return reviews.compute_reviewer_response_state(self._runtime(), issue_number, review_data, issue_snapshot=issue_snapshot) + + +class _BootstrapWorkflowAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + def process_pass_until_expirations(self, state): return process_pass_until_expirations(state) def sync_members_with_queue(self, current_state): return sync_members_with_queue(self._runtime(), current_state) + def sync_status_labels_for_items(self, current_state, issue_numbers): + return reviews.sync_status_labels_for_items(self._runtime(), current_state, issue_numbers) + def fetch_members(self): return members.fetch_members(self._runtime()) - def parse_iso8601_timestamp(self, value): - return state_store.parse_iso8601_timestamp(value) - def compute_reviewer_response_state(self, issue_number, review_data, *, issue_snapshot=None): - return reviews.compute_reviewer_response_state(self._runtime(), issue_number, review_data, issue_snapshot=issue_snapshot) +class _BootstrapAutomationAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter - def sync_status_labels_for_items(self, current_state, issue_numbers): - return reviews.sync_status_labels_for_items(self._runtime(), current_state, issue_numbers) + def _runtime(self): + return self._runtime_getter() def run_command(self, command, cwd=None, check=True): return automation.run_command(command, cwd, check) @@ -292,9 +313,20 @@ def create_pull_request(self, branch, base, issue_number): def parse_issue_labels(self): return automation.bot_parse_issue_labels(self._runtime()) + +class _BootstrapStateLockAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + def normalize_lock_metadata(self, lock_meta): return state_store.normalize_lock_metadata(lock_meta) + def parse_iso8601_timestamp(self, value): + return state_store.parse_iso8601_timestamp(value) + def get_state_issue(self): return state_store.get_state_issue(self._runtime()) @@ -350,6 +382,15 @@ def get_active_lease_context(self): return self._runtime().ACTIVE_LEASE_CONTEXT +class _BootstrapAdapterGroups: + def __init__(self, *, github, review, workflow, automation, state_lock): + self.github = github + self.review = review + self.workflow = workflow + self.automation = automation + self.state_lock = state_lock + + def build_runtime(*, requests, sys, random, time, active_lease_context=None) -> ReviewerBotRuntime: runtime: ReviewerBotRuntime | None = None @@ -365,7 +406,13 @@ def runtime_getter() -> ReviewerBotRuntime: github_services = _BootstrapGitHubServices(runtime_getter) lock_services = _BootstrapLockServices(runtime_getter) handlers = _BootstrapHandlerServices(runtime_getter) - adapters = _BootstrapAdapterServices(runtime_getter) + adapters = _BootstrapAdapterGroups( + github=_BootstrapGitHubAdapterServices(runtime_getter), + review=_BootstrapReviewAdapterServices(runtime_getter), + workflow=_BootstrapWorkflowAdapterServices(runtime_getter), + automation=_BootstrapAutomationAdapterServices(runtime_getter), + state_lock=_BootstrapStateLockAdapterServices(runtime_getter), + ) runtime = ReviewerBotRuntime( requests=requests, diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 78562ad97..399e87d71 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -396,7 +396,7 @@ def resolve_workflow_run_pr_number( def handle_sync_members_command(bot, state: dict) -> tuple[str, bool]: - state, changes = bot.adapters.sync_members_with_queue(state) + state, changes = bot.adapters.workflow.sync_members_with_queue(state) if changes: changes_text = "\n".join(f"- {change}" for change in changes) return f"✅ Queue synced with members.md:\n\n{changes_text}", True diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index ae1097366..fbf7401c4 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -155,7 +155,7 @@ def handle_manual_dispatch(bot, state: dict) -> bool: return False bot.assert_lock_held("handle_manual_dispatch") if action == "sync-members": - _, changes = bot.adapters.sync_members_with_queue(state) + _, changes = bot.adapters.workflow.sync_members_with_queue(state) return bool(changes) if action == "repair-review-status-labels": for issue_number in bot.list_open_items_with_status_labels(): diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index cea32060c..5e36e1df7 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -372,133 +372,130 @@ def drain_touched_items(self) -> list[int]: return self.touch_tracker.drain() def assert_lock_held(self, context: str) -> None: - return self.adapters.assert_lock_held(context) + return self.adapters.state_lock.assert_lock_held(context) def get_github_token(self) -> str: - return self.adapters.get_github_token() + return self.adapters.github.get_github_token() def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: - return self.adapters.get_github_graphql_token(prefer_board_token=prefer_board_token) + return self.adapters.github.get_github_graphql_token(prefer_board_token=prefer_board_token) def github_graphql(self, query: str, variables=None, *, token=None): - return self.adapters.github_graphql(query, variables, token=token) + return self.adapters.github.github_graphql(query, variables, token=token) def post_comment(self, issue_number: int, body: str) -> bool: - return self.adapters.post_comment(issue_number, body) + return self.adapters.github.post_comment(issue_number, body) def get_repo_labels(self): - return self.adapters.get_repo_labels() + return self.adapters.github.get_repo_labels() def add_label(self, issue_number: int, label: str) -> bool: - return self.adapters.add_label(issue_number, label) + return self.adapters.github.add_label(issue_number, label) def remove_label(self, issue_number: int, label: str) -> bool: - return self.adapters.remove_label(issue_number, label) + return self.adapters.github.remove_label(issue_number, label) def ensure_label_exists(self, label: str, *, color: str | None = None, description: str | None = None) -> bool: - return self.adapters.ensure_label_exists(label, color=color, description=description) + return self.adapters.github.ensure_label_exists(label, color=color, description=description) def get_issue_assignees(self, issue_number: int): - return self.adapters.get_issue_assignees(issue_number) + return self.adapters.github.get_issue_assignees(issue_number) def request_reviewer_assignment(self, issue_number: int, username: str): - return self.adapters.request_reviewer_assignment(issue_number, username) + return self.adapters.github.request_reviewer_assignment(issue_number, username) def get_assignment_failure_comment(self, reviewer: str, attempt): - return self.adapters.get_assignment_failure_comment(reviewer, attempt) + return self.adapters.github.get_assignment_failure_comment(reviewer, attempt) def add_reaction(self, comment_id: int, reaction: str) -> bool: - return self.adapters.add_reaction(comment_id, reaction) + return self.adapters.github.add_reaction(comment_id, reaction) def remove_assignee(self, issue_number: int, username: str) -> bool: - return self.adapters.remove_assignee(issue_number, username) + return self.adapters.github.remove_assignee(issue_number, username) def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: - return self.adapters.remove_pr_reviewer(issue_number, username) + return self.adapters.github.remove_pr_reviewer(issue_number, username) def unassign_reviewer(self, issue_number: int, username: str) -> bool: - return self.adapters.unassign_reviewer(issue_number, username) + return self.adapters.github.unassign_reviewer(issue_number, username) def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: - return self.adapters.get_user_permission_status(username, required_permission) + return self.adapters.github.get_user_permission_status(username, required_permission) def check_user_permission(self, username: str, required_permission: str = "triage"): - return self.adapters.check_user_permission(username, required_permission) + return self.adapters.github.check_user_permission(username, required_permission) def get_issue_or_pr_snapshot(self, issue_number: int): - return self.adapters.get_issue_or_pr_snapshot(issue_number) + return self.adapters.github.get_issue_or_pr_snapshot(issue_number) def get_pull_request_reviews(self, issue_number: int): - return self.adapters.get_pull_request_reviews(issue_number) + return self.adapters.github.get_pull_request_reviews(issue_number) def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): - return self.adapters.maybe_record_head_observation_repair(issue_number, review_data) + return self.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) - # Adapter-only mutable review-state compatibility surface. - # Ownership lives in review_state.py; these methods remain so runtime-oriented - # callers and test doubles can delegate through one adapter seam. def get_next_reviewer(self, state: dict, skip_usernames=None): - return self.adapters.get_next_reviewer(state, skip_usernames) + return self.adapters.review.get_next_reviewer(state, skip_usernames) def strip_code_blocks(self, comment_body: str) -> str: - return self.adapters.strip_code_blocks(comment_body) + return self.adapters.review.strip_code_blocks(comment_body) def parse_command(self, comment_body: str): - return self.adapters.parse_command(comment_body) + return self.adapters.review.parse_command(comment_body) def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: - return self.adapters.record_assignment(state, github, issue_number, kind) + return self.adapters.review.record_assignment(state, github, issue_number, kind) def reposition_member_as_next(self, state: dict, username: str) -> bool: - return self.adapters.reposition_member_as_next(state, username) + return self.adapters.review.reposition_member_as_next(state, username) def parse_iso8601_timestamp(self, value: Any): - return self.adapters.parse_iso8601_timestamp(value) + return self.adapters.state_lock.parse_iso8601_timestamp(value) def compute_reviewer_response_state(self, issue_number: int, review_data: dict, *, issue_snapshot=None): - return self.adapters.compute_reviewer_response_state(issue_number, review_data, issue_snapshot=issue_snapshot) + return self.adapters.review.compute_reviewer_response_state(issue_number, review_data, issue_snapshot=issue_snapshot) def run_command(self, command, cwd, check=False): - return self.adapters.run_command(command, cwd, check) + return self.adapters.automation.run_command(command, cwd, check) def summarize_output(self, result, limit: int = 20) -> str: - return self.adapters.summarize_output(result, limit) + return self.adapters.automation.summarize_output(result, limit) def list_changed_files(self, repo_root): - return self.adapters.list_changed_files(repo_root) + return self.adapters.automation.list_changed_files(repo_root) def get_default_branch(self) -> str: - return self.adapters.get_default_branch() + return self.adapters.automation.get_default_branch() def find_open_pr_for_branch_status(self, branch: str): - return self.adapters.find_open_pr_for_branch_status(branch) + return self.adapters.automation.find_open_pr_for_branch_status(branch) def create_pull_request(self, branch: str, base: str, issue_number: int): - return self.adapters.create_pull_request(branch, base, issue_number) + return self.adapters.automation.create_pull_request(branch, base, issue_number) def parse_issue_labels(self) -> list[str]: - return self.adapters.parse_issue_labels() + return self.adapters.automation.parse_issue_labels() def normalize_lock_metadata(self, lock_meta: dict | None): - return self.adapters.normalize_lock_metadata(lock_meta) + return self.adapters.state_lock.normalize_lock_metadata(lock_meta) def get_state_issue(self): - return self.adapters.get_state_issue() + return self.adapters.state_lock.get_state_issue() def clear_lock_metadata(self): - return self.adapters.clear_lock_metadata() + return self.adapters.state_lock.clear_lock_metadata() def get_state_issue_snapshot(self): - return self.adapters.get_state_issue_snapshot() + return self.adapters.state_lock.get_state_issue_snapshot() def conditional_patch_state_issue(self, body: str, etag: str | None = None): - return self.adapters.conditional_patch_state_issue(body, etag) + return self.adapters.state_lock.conditional_patch_state_issue(body, etag) def parse_lock_metadata_from_issue_body(self, body: str): - return self.adapters.parse_lock_metadata_from_issue_body(body) + return self.adapters.state_lock.parse_lock_metadata_from_issue_body(body) def render_state_issue_body(self, state: dict, lock_meta: dict, base_body: str | None = None, *, preserve_state_block: bool = False): - return self.adapters.render_state_issue_body( + return self.adapters.state_lock.render_state_issue_body( state, lock_meta, base_body, @@ -506,42 +503,42 @@ def render_state_issue_body(self, state: dict, lock_meta: dict, base_body: str | ) def get_state_issue_html_url(self): - return self.adapters.get_state_issue_html_url() + return self.adapters.state_lock.get_state_issue_html_url() def get_lock_ref_display(self): - return self.adapters.get_lock_ref_display() + return self.adapters.state_lock.get_lock_ref_display() def get_lock_ref_snapshot(self): - return self.adapters.get_lock_ref_snapshot() + return self.adapters.state_lock.get_lock_ref_snapshot() def build_lock_metadata(self, *args, **kwargs): - return self.adapters.build_lock_metadata(*args, **kwargs) + return self.adapters.state_lock.build_lock_metadata(*args, **kwargs) def create_lock_commit(self, parent_sha: str, tree_sha: str, lock_meta: dict): - return self.adapters.create_lock_commit(parent_sha, tree_sha, lock_meta) + return self.adapters.state_lock.create_lock_commit(parent_sha, tree_sha, lock_meta) def cas_update_lock_ref(self, new_sha: str): - return self.adapters.cas_update_lock_ref(new_sha) + return self.adapters.state_lock.cas_update_lock_ref(new_sha) def lock_is_currently_valid(self, lock_meta: dict, now: datetime | None = None): - return self.adapters.lock_is_currently_valid(lock_meta, now) + return self.adapters.state_lock.lock_is_currently_valid(lock_meta, now) def renew_state_issue_lease_lock(self, context): - result = self.adapters.renew_state_issue_lease_lock(context) - self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + result = self.adapters.state_lock.renew_state_issue_lease_lock(context) + self.ACTIVE_LEASE_CONTEXT = self.adapters.state_lock.get_active_lease_context() return result def ensure_state_issue_lease_lock_fresh(self) -> bool: - return self.adapters.ensure_state_issue_lease_lock_fresh() + return self.adapters.state_lock.ensure_state_issue_lease_lock_fresh() def acquire_state_issue_lease_lock(self): - context = self.adapters.acquire_state_issue_lease_lock() - self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + context = self.adapters.state_lock.acquire_state_issue_lease_lock() + self.ACTIVE_LEASE_CONTEXT = self.adapters.state_lock.get_active_lease_context() return context def release_state_issue_lease_lock(self) -> bool: - result = self.adapters.release_state_issue_lease_lock() - self.ACTIVE_LEASE_CONTEXT = self.adapters.get_active_lease_context() + result = self.adapters.state_lock.release_state_issue_lease_lock() + self.ACTIVE_LEASE_CONTEXT = self.adapters.state_lock.get_active_lease_context() return result def fetch_members(self): diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 355eaa255..86e9f481f 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -69,7 +69,7 @@ def test_runtime_head_repair_contract_is_runtime_scoped(): def test_runtime_review_state_adapter_mutates_active_reviews(): state = make_state() - review = reviewer_bot._runtime_bot().domain.adapters.ensure_review_entry(state, 42, create=True) + review = reviewer_bot._runtime_bot().adapters.review.ensure_review_entry(state, 42, create=True) assert review is state["active_reviews"]["42"] @@ -328,7 +328,6 @@ def test_runtime_exposes_explicit_infra_and_domain_service_groups(): assert runtime.domain.github is runtime.github assert runtime.domain.locks is runtime.locks assert runtime.domain.handlers is runtime.handlers - assert runtime.domain.adapters is runtime.adapters def test_bootstrap_runtime_wires_explicit_config_output_and_deferred_services(): @@ -362,9 +361,14 @@ def test_bootstrap_runtime_wires_explicit_handler_services(): def test_bootstrap_runtime_wires_explicit_adapter_services(): runtime = reviewer_bot._runtime_bot() - assert hasattr(runtime.adapters, "get_github_token") - assert hasattr(runtime.adapters, "handle_pass_command") - assert hasattr(runtime.adapters, "render_state_issue_body") + assert hasattr(runtime.adapters, "github") + assert hasattr(runtime.adapters, "review") + assert hasattr(runtime.adapters, "workflow") + assert hasattr(runtime.adapters, "automation") + assert hasattr(runtime.adapters, "state_lock") + assert hasattr(runtime.adapters.github, "get_github_token") + assert hasattr(runtime.adapters.review, "handle_pass_command") + assert hasattr(runtime.adapters.state_lock, "render_state_issue_body") def test_default_stderr_logger_renders_message_and_sorted_fields(): diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index b247d8e36..0d9c00065 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -78,6 +78,7 @@ def __init__(self, *, state_store, github, locks, handlers, workflow, adapters): class FakeRuntimeAdapterServices: def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime + self.workflow = runtime.workflow def process_pass_until_expirations(self, state: dict): return self._runtime.workflow.process_pass_until_expirations(state) From dd01b8688a96d82df70c9341d70f85d9e200e434 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:11:53 +0900 Subject: [PATCH 187/209] test(reviewer-bot): reduce fake runtime to a thin compatibility shell --- .../test_comment_application_contract.py | 2 +- .../test_fake_runtime_contract.py | 10 + tests/fixtures/fake_runtime.py | 366 +++++++++++++++--- 3 files changed, 314 insertions(+), 64 deletions(-) diff --git a/tests/contract/reviewer_bot/test_comment_application_contract.py b/tests/contract/reviewer_bot/test_comment_application_contract.py index 30164bb61..66cd24684 100644 --- a/tests/contract/reviewer_bot/test_comment_application_contract.py +++ b/tests/contract/reviewer_bot/test_comment_application_contract.py @@ -43,7 +43,7 @@ def test_comment_application_stores_pending_privileged_command_from_typed_reques comment_author="dana", comment_body="@guidelines-bot /accept-no-fls-changes", ) - harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.set_config_value("ISSUE_LABELS", f'["{FLS_AUDIT_LABEL}"]') harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" harness.runtime.post_comment = lambda issue_number, body: True diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 49d49c159..347624ac2 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -44,6 +44,7 @@ def test_fake_runtime_exposes_explicit_service_fields_and_no_omnibus_service_con assert runtime.touch_tracker is not None assert runtime.infra is not None assert runtime.domain is not None + assert runtime.compat is not None assert hasattr(runtime, "services") is False assert hasattr(runtime, "components") is False @@ -214,3 +215,12 @@ def test_fake_runtime_groups_focused_services_into_infra_and_domain_shells(monke assert runtime.domain.locks is runtime.locks assert runtime.domain.handlers is runtime.handlers assert runtime.domain.workflow is runtime.workflow + + +def test_fake_runtime_exposes_compatibility_groups_for_thin_delegation(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert hasattr(runtime.compat, "github") + assert hasattr(runtime.compat, "review") + assert hasattr(runtime.compat, "state_lock") + assert hasattr(runtime.compat, "automation") diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 0d9c00065..341ac67aa 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -4,6 +4,7 @@ import sys import time from datetime import datetime, timezone +from types import SimpleNamespace from typing import Any, Callable from scripts.reviewer_bot_lib import automation as automation_module @@ -11,6 +12,7 @@ from scripts.reviewer_bot_lib import comment_routing as comment_routing_module from scripts.reviewer_bot_lib import config as config_module from scripts.reviewer_bot_lib import github_api as github_api_module +from scripts.reviewer_bot_lib import lease_lock as lease_lock_module from scripts.reviewer_bot_lib import lifecycle as lifecycle_module from scripts.reviewer_bot_lib import maintenance as maintenance_module from scripts.reviewer_bot_lib import queue as queue_module @@ -66,13 +68,14 @@ def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport class FakeRuntimeDomainServices: - def __init__(self, *, state_store, github, locks, handlers, workflow, adapters): + def __init__(self, *, state_store, github, locks, handlers, workflow, adapters, compat): self.state_store = state_store self.github = github self.locks = locks self.handlers = handlers self.workflow = workflow self.adapters = adapters + self.compat = compat class FakeRuntimeAdapterServices: @@ -90,6 +93,244 @@ def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._runtime.workflow.sync_status_labels_for_items(state, issue_numbers) +class FakeRuntimeGitHubCompatibility: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def get_issue_assignees(self, issue_number: int): + return github_api_module.get_issue_assignees(self._runtime, issue_number) + + def request_reviewer_assignment(self, issue_number: int, username: str): + return github_api_module.request_reviewer_assignment(self._runtime, issue_number, username) + + def get_assignment_failure_comment(self, reviewer: str, attempt): + return github_api_module.get_assignment_failure_comment(self._runtime, reviewer, attempt) + + def post_comment(self, issue_number: int, body: str) -> bool: + return github_api_module.post_comment(self._runtime, issue_number, body) + + def add_reaction(self, comment_id: int, reaction: str) -> bool: + return github_api_module.add_reaction(self._runtime, comment_id, reaction) + + def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: + return github_api_module.get_user_permission_status(self._runtime, username, required_permission) + + def check_user_permission(self, username: str, required_permission: str = "triage"): + return github_api_module.check_user_permission(self._runtime, username, required_permission) + + def get_repo_labels(self): + return github_api_module.get_repo_labels(self._runtime) + + def add_label(self, issue_number: int, label: str) -> bool: + return github_api_module.add_label(self._runtime, issue_number, label) + + def remove_label(self, issue_number: int, label: str) -> bool: + return github_api_module.remove_label(self._runtime, issue_number, label) + + def remove_assignee(self, issue_number: int, username: str) -> bool: + return github_api_module.remove_assignee(self._runtime, issue_number, username) + + def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: + return github_api_module.remove_pr_reviewer(self._runtime, issue_number, username) + + def unassign_reviewer(self, issue_number: int, username: str) -> bool: + return github_api_module.unassign_reviewer(self._runtime, issue_number, username) + + def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: + payload = self._runtime.github_api("GET", f"issues/{issue_number}") + return payload if isinstance(payload, dict) else None + + def get_pull_request_reviews(self, issue_number: int): + return reviews_module.get_pull_request_reviews(self._runtime, issue_number) + + def github_graphql(self, query: str, variables=None, *, token=None): + return github_api_module.github_graphql(self._runtime, query, variables, token=token) + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: + if prefer_board_token: + token = self._runtime.get_config_value("REVIEWER_BOARD_TOKEN") + if token: + return token + token = self._runtime.get_config_value("GITHUB_GRAPHQL_TOKEN") or self._runtime.get_config_value("GITHUB_TOKEN") + if token: + return token + raise RuntimeError("REVIEWER_BOARD_TOKEN not set") + + +class FakeRuntimeReviewCompatibility: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): + return lifecycle_module.maybe_record_head_observation_repair(self._runtime, issue_number, review_data) + + def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: + return lifecycle_module.handle_transition_notice(self._runtime, state, issue_number, reviewer) + + def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): + return self.compat.review.ensure_review_entry(state, issue_number, create=create) + + def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str | None = None) -> None: + return self.compat.review.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + + def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: + return self.compat.review.mark_review_complete(state, issue_number, reviewer, source) + + def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: + return self.compat.review.update_reviewer_activity(state, issue_number, reviewer) + + def list_open_items_with_status_labels(self) -> list[int]: + return reviews_module.list_open_items_with_status_labels(self._runtime) + + def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request=None): + return commands_module.handle_pass_command(self._runtime, state, issue_number, comment_author, reason, request=request) + + def handle_pass_until_command(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None, request=None): + return commands_module.handle_pass_until_command(self._runtime, state, issue_number, comment_author, return_date, reason, request=request) + + def handle_label_command(self, state: dict, issue_number: int, label_string: str, request=None): + return commands_module.handle_label_command(self._runtime, state, issue_number, label_string, request=request) + + def handle_sync_members_command(self, state: dict): + return commands_module.handle_sync_members_command(self._runtime, state) + + def handle_queue_command(self, state: dict): + return commands_module.handle_queue_command(self._runtime, state) + + def handle_commands_command(self): + return commands_module.handle_commands_command(self._runtime) + + def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request=None): + return commands_module.handle_claim_command(self._runtime, state, issue_number, comment_author, request=request) + + def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request=None): + return commands_module.handle_release_command(self._runtime, state, issue_number, comment_author, args, request=request) + + def handle_assign_command(self, state: dict, issue_number: int, username: str, request=None): + return commands_module.handle_assign_command(self._runtime, state, issue_number, username, request=request) + + def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): + return commands_module.handle_assign_from_queue_command(self._runtime, state, issue_number, request=request) + + def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): + return reconcile_module.handle_rectify_command(self._runtime, state, issue_number, comment_author) + + def get_commands_help(self) -> str: + return config_module.get_commands_help() + + def get_next_reviewer(self, state: dict, skip_usernames=None): + return queue_module.get_next_reviewer(state, skip_usernames) + + def strip_code_blocks(self, comment_body: str) -> str: + return commands_module.strip_code_blocks(comment_body) + + def parse_command(self, comment_body: str): + return commands_module.parse_command(self._runtime, comment_body) + + def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: + return queue_module.record_assignment(state, github, issue_number, kind) + + def reposition_member_as_next(self, state: dict, username: str) -> bool: + return queue_module.reposition_member_as_next(state, username) + + def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): + return reviews_module.compute_reviewer_response_state(self._runtime, issue_number, state, issue_snapshot=issue_snapshot) + + +class FakeRuntimeStateLockCompatibility: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def parse_iso8601_timestamp(self, value: Any): + return state_store_module.parse_iso8601_timestamp(value) + + def normalize_lock_metadata(self, lock_meta: dict | None): + return state_store_module.normalize_lock_metadata(lock_meta) + + def get_state_issue(self): + return state_store_module.get_state_issue(self._runtime) + + def clear_lock_metadata(self): + return lease_lock_module.clear_lock_metadata(self._runtime) + + def get_state_issue_snapshot(self): + return state_store_module.get_state_issue_snapshot(self._runtime) + + def conditional_patch_state_issue(self, body: str, etag: str | None = None): + return state_store_module.conditional_patch_state_issue(self._runtime, body, etag) + + def parse_lock_metadata_from_issue_body(self, body: str): + return state_store_module.parse_lock_metadata_from_issue_body(body) + + def render_state_issue_body(self, state: dict, lock_meta: dict, base_body: str | None = None, *, preserve_state_block: bool = False): + return state_store_module.render_state_issue_body(state, lock_meta, base_body, preserve_state_block=preserve_state_block) + + def get_state_issue_html_url(self): + return lease_lock_module.get_state_issue_html_url(self._runtime) + + def get_lock_ref_display(self): + return lease_lock_module.get_lock_ref_display(self._runtime) + + def get_lock_ref_snapshot(self): + return lease_lock_module.get_lock_ref_snapshot(self._runtime) + + def build_lock_metadata(self, *args, **kwargs): + return lease_lock_module.build_lock_metadata(self._runtime, *args, **kwargs) + + def create_lock_commit(self, parent_sha, tree_sha, lock_meta): + return lease_lock_module.create_lock_commit(self._runtime, parent_sha, tree_sha, lock_meta) + + def cas_update_lock_ref(self, new_sha): + return lease_lock_module.cas_update_lock_ref(self._runtime, new_sha) + + def lock_is_currently_valid(self, lock_meta: dict, now=None): + return lease_lock_module.lock_is_currently_valid(self._runtime, lock_meta, now) + + def renew_state_issue_lease_lock(self, context): + return lease_lock_module.renew_state_issue_lease_lock(self._runtime, context) + + def ensure_state_issue_lease_lock_fresh(self): + return self._runtime.locks.refresh() + + def acquire_state_issue_lease_lock(self): + return self._runtime.locks.acquire() + + def release_state_issue_lease_lock(self): + return self._runtime.locks.release() + + +class FakeRuntimeAutomationCompatibility: + def __init__(self, runtime: "FakeReviewerBotRuntime"): + self._runtime = runtime + + def run_command(self, command, cwd, check=False): + return automation_module.run_command(command, cwd=cwd, check=check) + + def summarize_output(self, result, limit: int = 20) -> str: + return automation_module.summarize_output(result, limit=limit) + + def list_changed_files(self, repo_root): + return automation_module.list_changed_files(repo_root) + + def get_default_branch(self) -> str: + return automation_module.get_default_branch(self._runtime) + + def find_open_pr_for_branch_status(self, branch: str): + return automation_module.find_open_pr_for_branch_status(self._runtime, branch) + + def create_pull_request(self, branch: str, base: str, issue_number: int): + return automation_module.create_pull_request(self._runtime, branch, base, issue_number) + + def parse_issue_labels(self) -> list[str]: + return automation_module.bot_parse_issue_labels(self._runtime) + + def fetch_members(self): + return self._runtime._fetch_members() + + def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): + return automation_module.handle_accept_no_fls_changes_command(self._runtime, issue_number, comment_author, request=request) + + class FakeReviewerBotRuntime: BOT_NAME = BOT_NAME BOT_MENTION = BOT_MENTION @@ -135,6 +376,12 @@ def __init__(self, monkeypatch, *, github=None): self.workflow = WorkflowBehaviorStub() self._fetch_members = lambda: [] self.adapters = FakeRuntimeAdapterServices(self) + self.compat = SimpleNamespace( + github=FakeRuntimeGitHubCompatibility(self), + review=FakeRuntimeReviewCompatibility(self), + state_lock=FakeRuntimeStateLockCompatibility(self), + automation=FakeRuntimeAutomationCompatibility(self), + ) self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -166,6 +413,7 @@ def __init__(self, monkeypatch, *, github=None): handlers=self.handlers, workflow=self.workflow, adapters=self.adapters, + compat=self.compat, ) def get_config_value(self, name: str, default: str = "") -> str: @@ -220,13 +468,13 @@ def save_state(self, state: dict) -> bool: return self.state_store.save_state(state) def ensure_state_issue_lease_lock_fresh(self) -> bool: - return self.locks.refresh() + return self.compat.state_lock.ensure_state_issue_lease_lock_fresh() def acquire_state_issue_lease_lock(self): - return self.locks.acquire() + return self.compat.state_lock.acquire_state_issue_lease_lock() def release_state_issue_lease_lock(self) -> bool: - return self.locks.release() + return self.compat.state_lock.release_state_issue_lease_lock() def process_pass_until_expirations(self, state: dict): return self.workflow.process_pass_until_expirations(state) @@ -241,65 +489,64 @@ def ensure_review_entry(self, state: dict, issue_number: int, create: bool = Fal return review_state_module.ensure_review_entry(state, issue_number, create=create) def get_issue_assignees(self, issue_number: int): - return github_api_module.get_issue_assignees(self, issue_number) + return self.compat.github.get_issue_assignees(issue_number) def request_reviewer_assignment(self, issue_number: int, username: str): - return github_api_module.request_reviewer_assignment(self, issue_number, username) + return self.compat.github.request_reviewer_assignment(issue_number, username) def get_assignment_failure_comment(self, reviewer: str, attempt): - return github_api_module.get_assignment_failure_comment(self, reviewer, attempt) + return self.compat.github.get_assignment_failure_comment(reviewer, attempt) def post_comment(self, issue_number: int, body: str) -> bool: - return github_api_module.post_comment(self, issue_number, body) + return self.compat.github.post_comment(issue_number, body) def add_reaction(self, comment_id: int, reaction: str) -> bool: - return github_api_module.add_reaction(self, comment_id, reaction) + return self.compat.github.add_reaction(comment_id, reaction) def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: - return github_api_module.get_user_permission_status(self, username, required_permission) + return self.compat.github.get_user_permission_status(username, required_permission) def check_user_permission(self, username: str, required_permission: str = "triage"): - return github_api_module.check_user_permission(self, username, required_permission) + return self.compat.github.check_user_permission(username, required_permission) def get_repo_labels(self): - return github_api_module.get_repo_labels(self) + return self.compat.github.get_repo_labels() def add_label(self, issue_number: int, label: str) -> bool: - return github_api_module.add_label(self, issue_number, label) + return self.compat.github.add_label(issue_number, label) def remove_label(self, issue_number: int, label: str) -> bool: - return github_api_module.remove_label(self, issue_number, label) + return self.compat.github.remove_label(issue_number, label) def remove_assignee(self, issue_number: int, username: str) -> bool: - return github_api_module.remove_assignee(self, issue_number, username) + return self.compat.github.remove_assignee(issue_number, username) def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: - return github_api_module.remove_pr_reviewer(self, issue_number, username) + return self.compat.github.remove_pr_reviewer(issue_number, username) def unassign_reviewer(self, issue_number: int, username: str) -> bool: - return github_api_module.unassign_reviewer(self, issue_number, username) + return self.compat.github.unassign_reviewer(issue_number, username) def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: - payload = self.github_api("GET", f"issues/{issue_number}") - return payload if isinstance(payload, dict) else None + return self.compat.github.get_issue_or_pr_snapshot(issue_number) def get_pull_request_reviews(self, issue_number: int): - return reviews_module.get_pull_request_reviews(self, issue_number) + return self.compat.github.get_pull_request_reviews(issue_number) def list_open_items_with_status_labels(self) -> list[int]: - return reviews_module.list_open_items_with_status_labels(self) + return self.compat.review.list_open_items_with_status_labels() def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): - return lifecycle_module.maybe_record_head_observation_repair(self, issue_number, review_data) + return self.compat.review.maybe_record_head_observation_repair(issue_number, review_data) def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str | None = None) -> None: - return review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + return self.compat.review.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: - return review_state_module.mark_review_complete(state, issue_number, reviewer, source) + return self.compat.review.mark_review_complete(state, issue_number, reviewer, source) def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: - return review_state_module.update_reviewer_activity(state, issue_number, reviewer) + return self.compat.review.update_reviewer_activity(state, issue_number, reviewer) def accept_reviewer_review_from_live_review(self, review_data: dict, review: dict, *, actor: str | None = None) -> bool: return review_state_module.accept_reviewer_review_from_live_review(review_data, review, actor=actor) @@ -324,25 +571,25 @@ def semantic_key_seen(self, review_data: dict, channel_name: str, semantic_key: return review_state_module.semantic_key_seen(review_data, channel_name, semantic_key) def get_next_reviewer(self, state: dict, skip_usernames=None): - return queue_module.get_next_reviewer(state, skip_usernames) + return self.compat.review.get_next_reviewer(state, skip_usernames) def strip_code_blocks(self, comment_body: str) -> str: - return commands_module.strip_code_blocks(comment_body) + return self.compat.review.strip_code_blocks(comment_body) def parse_command(self, comment_body: str): - return commands_module.parse_command(self, comment_body) + return self.compat.review.parse_command(comment_body) def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: - return queue_module.record_assignment(state, github, issue_number, kind) + return self.compat.review.record_assignment(state, github, issue_number, kind) def reposition_member_as_next(self, state: dict, username: str) -> bool: - return queue_module.reposition_member_as_next(state, username) + return self.compat.review.reposition_member_as_next(state, username) def parse_iso8601_timestamp(self, value: Any): - return state_store_module.parse_iso8601_timestamp(value) + return self.compat.state_lock.parse_iso8601_timestamp(value) def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): - return reviews_module.compute_reviewer_response_state(self, issue_number, state, issue_snapshot=issue_snapshot) + return self.compat.review.compute_reviewer_response_state(issue_number, state, issue_snapshot=issue_snapshot) def rebuild_pr_approval_state(self, *args, pull_request=None, reviews=None): if len(args) == 2: @@ -360,83 +607,76 @@ def rebuild_pr_approval_state(self, *args, pull_request=None, reviews=None): ) def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: - if prefer_board_token: - token = self.get_config_value("REVIEWER_BOARD_TOKEN") - if token: - return token - token = self.get_config_value("GITHUB_GRAPHQL_TOKEN") or self.get_config_value("GITHUB_TOKEN") - if token: - return token - raise RuntimeError("REVIEWER_BOARD_TOKEN not set") + return self.compat.github.get_github_graphql_token(prefer_board_token=prefer_board_token) def github_graphql(self, query: str, variables=None, *, token=None): - return github_api_module.github_graphql(self, query, variables, token=token) + return self.compat.github.github_graphql(query, variables, token=token) def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: - return lifecycle_module.handle_transition_notice(self, state, issue_number, reviewer) + return self.compat.review.handle_transition_notice(state, issue_number, reviewer) def handle_pass_command(self, state: dict, issue_number: int, comment_author: str, reason: str | None, request=None): - return commands_module.handle_pass_command(self, state, issue_number, comment_author, reason, request=request) + return self.compat.review.handle_pass_command(state, issue_number, comment_author, reason, request=request) def handle_pass_until_command(self, state: dict, issue_number: int, comment_author: str, return_date: str, reason: str | None, request=None): - return commands_module.handle_pass_until_command(self, state, issue_number, comment_author, return_date, reason, request=request) + return self.compat.review.handle_pass_until_command(state, issue_number, comment_author, return_date, reason, request=request) def handle_label_command(self, state: dict, issue_number: int, label_string: str, request=None): - return commands_module.handle_label_command(self, state, issue_number, label_string, request=request) + return self.compat.review.handle_label_command(state, issue_number, label_string, request=request) def handle_sync_members_command(self, state: dict): - return commands_module.handle_sync_members_command(self, state) + return self.compat.review.handle_sync_members_command(state) def handle_queue_command(self, state: dict): - return commands_module.handle_queue_command(self, state) + return self.compat.review.handle_queue_command(state) def handle_commands_command(self): - return commands_module.handle_commands_command(self) + return self.compat.review.handle_commands_command() def handle_claim_command(self, state: dict, issue_number: int, comment_author: str, request=None): - return commands_module.handle_claim_command(self, state, issue_number, comment_author, request=request) + return self.compat.review.handle_claim_command(state, issue_number, comment_author, request=request) def handle_release_command(self, state: dict, issue_number: int, comment_author: str, args=None, request=None): - return commands_module.handle_release_command(self, state, issue_number, comment_author, args, request=request) + return self.compat.review.handle_release_command(state, issue_number, comment_author, args, request=request) def handle_assign_command(self, state: dict, issue_number: int, username: str, request=None): - return commands_module.handle_assign_command(self, state, issue_number, username, request=request) + return self.compat.review.handle_assign_command(state, issue_number, username, request=request) def handle_assign_from_queue_command(self, state: dict, issue_number: int, request=None): - return commands_module.handle_assign_from_queue_command(self, state, issue_number, request=request) + return self.compat.review.handle_assign_from_queue_command(state, issue_number, request=request) def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): - return automation_module.handle_accept_no_fls_changes_command(self, issue_number, comment_author, request=request) + return self.compat.automation.handle_accept_no_fls_changes_command(issue_number, comment_author, request=request) def handle_rectify_command(self, state: dict, issue_number: int, comment_author: str): - return reconcile_module.handle_rectify_command(self, state, issue_number, comment_author) + return self.compat.review.handle_rectify_command(state, issue_number, comment_author) def get_commands_help(self) -> str: - return config_module.get_commands_help() + return self.compat.review.get_commands_help() def parse_issue_labels(self) -> list[str]: - return automation_module.bot_parse_issue_labels(self) + return self.compat.automation.parse_issue_labels() def fetch_members(self): - return self._fetch_members() + return self.compat.automation.fetch_members() def run_command(self, command, cwd, check=False): - return automation_module.run_command(command, cwd=cwd, check=check) + return self.compat.automation.run_command(command, cwd, check=check) def summarize_output(self, result, limit: int = 20) -> str: - return automation_module.summarize_output(result, limit=limit) + return self.compat.automation.summarize_output(result, limit=limit) def list_changed_files(self, repo_root): - return automation_module.list_changed_files(repo_root) + return self.compat.automation.list_changed_files(repo_root) def get_default_branch(self) -> str: - return automation_module.get_default_branch(self) + return self.compat.automation.get_default_branch() def find_open_pr_for_branch_status(self, branch: str): - return automation_module.find_open_pr_for_branch_status(self, branch) + return self.compat.automation.find_open_pr_for_branch_status(branch) def create_pull_request(self, branch: str, base: str, issue_number: int): - return automation_module.create_pull_request(self, branch, base, issue_number) + return self.compat.automation.create_pull_request(branch, base, issue_number) def handle_issue_or_pr_opened(self, state: dict) -> bool: return self.handlers.call("handle_issue_or_pr_opened", state) From 0f365796456b6c2a47a6b54e4dd1432935e73784 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:12:29 +0900 Subject: [PATCH 188/209] test(reviewer-bot): align integration tests with fake runtime shell --- .../test_comment_routing_integration.py | 4 ++-- .../test_reconcile_privileged_command.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py index c31d4f76e..c6623b138 100644 --- a/tests/integration/reviewer_bot/test_comment_routing_integration.py +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -22,7 +22,7 @@ def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypa comment_body="@guidelines-bot /accept-no-fls-changes", ) effects = harness.capture_comment_side_effects() - harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.set_config_value("ISSUE_LABELS", f'["{FLS_AUDIT_LABEL}"]') harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" assert comment_routing.handle_comment_event(harness.runtime, state, request) is True @@ -65,7 +65,7 @@ def test_closed_non_pr_command_comment_does_not_create_pending_privileged_comman comment_body="@guidelines-bot /accept-no-fls-changes", ) effects = harness.capture_comment_side_effects() - harness.runtime.parse_issue_labels = lambda: [FLS_AUDIT_LABEL] + harness.runtime.set_config_value("ISSUE_LABELS", f'["{FLS_AUDIT_LABEL}"]') harness.runtime.check_user_permission = lambda username, required_permission="triage": True assert comment_routing.handle_comment_event(harness.runtime, state, request) is False diff --git a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py index b4398e12b..ff695f139 100644 --- a/tests/integration/reviewer_bot/test_reconcile_privileged_command.py +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -4,7 +4,7 @@ pytestmark = pytest.mark.integration -from scripts.reviewer_bot_lib import maintenance, review_state +from scripts.reviewer_bot_lib import automation, maintenance, review_state from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL from tests.fixtures.commands_harness import CommandHarness from tests.fixtures.reviewer_bot import make_state @@ -25,7 +25,7 @@ def test_execute_pending_privileged_command_revalidates_live_state(monkeypatch): harness.set_manual_dispatch(source_event_key="issue_comment:100") harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" - harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: ("ok", True) + monkeypatch.setattr(automation, "handle_accept_no_fls_changes_command", lambda bot, issue_number, actor, request=None: ("ok", True)) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert review["pending_privileged_commands"]["issue_comment:100"]["status"] == "executed" @@ -55,7 +55,7 @@ def fake_handle(issue_number, actor, request=None): observed["request"] = request return ("ok", True) - harness.runtime.handle_accept_no_fls_changes_command = fake_handle + monkeypatch.setattr(automation, "handle_accept_no_fls_changes_command", lambda bot, issue_number, actor, request=None: fake_handle(issue_number, actor, request)) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True request = observed["request"] @@ -87,7 +87,7 @@ def test_execute_pending_privileged_command_does_not_leak_issue_labels_env(monke monkeypatch.delenv("ISSUE_LABELS", raising=False) harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": FLS_AUDIT_LABEL}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" - harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: ("ok", True) + monkeypatch.setattr(automation, "handle_accept_no_fls_changes_command", lambda bot, issue_number, actor, request=None: ("ok", True)) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert "ISSUE_LABELS" not in os.environ @@ -108,7 +108,11 @@ def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_ harness.runtime.get_issue_or_pr_snapshot = lambda issue_number: {"number": issue_number, "labels": [{"name": "status: awaiting reviewer response"}]} harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "granted" called = {"handle": 0} - harness.runtime.handle_accept_no_fls_changes_command = lambda issue_number, actor, request=None: called.__setitem__("handle", called["handle"] + 1) or ("ok", True) + monkeypatch.setattr( + automation, + "handle_accept_no_fls_changes_command", + lambda bot, issue_number, actor, request=None: called.__setitem__("handle", called["handle"] + 1) or ("ok", True), + ) assert maintenance.handle_manual_dispatch(harness.runtime, state) is True assert called["handle"] == 0 From 627399c44f26d2c381921c3a23648c81917d3447 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:15:23 +0900 Subject: [PATCH 189/209] test(reviewer-bot): refine fake runtime compatibility delegation --- tests/fixtures/fake_runtime.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 341ac67aa..714ff6d35 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -168,16 +168,16 @@ def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str return lifecycle_module.handle_transition_notice(self._runtime, state, issue_number, reviewer) def ensure_review_entry(self, state: dict, issue_number: int, create: bool = False): - return self.compat.review.ensure_review_entry(state, issue_number, create=create) + return review_state_module.ensure_review_entry(state, issue_number, create=create) def set_current_reviewer(self, state: dict, issue_number: int, reviewer: str, assignment_method: str | None = None) -> None: - return self.compat.review.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) + return review_state_module.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) def mark_review_complete(self, state: dict, issue_number: int, reviewer: str | None, source: str) -> bool: - return self.compat.review.mark_review_complete(state, issue_number, reviewer, source) + return review_state_module.mark_review_complete(state, issue_number, reviewer, source) def update_reviewer_activity(self, state: dict, issue_number: int, reviewer: str) -> bool: - return self.compat.review.update_reviewer_activity(state, issue_number, reviewer) + return review_state_module.update_reviewer_activity(state, issue_number, reviewer) def list_open_items_with_status_labels(self) -> list[int]: return reviews_module.list_open_items_with_status_labels(self._runtime) From 5b3b48f9afe3e988ad2880ef44349648ac9cbd3f Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:20:42 +0900 Subject: [PATCH 190/209] test(reviewer-bot): converge transport fakes on shared authority --- tests/fixtures/focused_fake_services.py | 38 +++++++++++++++++-- .../reviewer_bot/test_fixture_contracts.py | 28 ++++++++++++++ tests/unit/reviewer_bot/test_github_api.py | 11 +++--- .../reviewer_bot/test_sweeper_downloads.py | 18 ++------- 4 files changed, 71 insertions(+), 24 deletions(-) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index e94da80be..09e7d946b 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -165,7 +165,7 @@ def request(self, method: str, url: str, *, headers=None, json_data=None, timeou endpoint = "/".join(parts[3:]) else: endpoint = parsed.path.lstrip("/") - result = self._runtime.github_api_request(method, endpoint, data=json_data) + result = self._runtime.github.github_api_request(method, endpoint, data=json_data) class _Response: def __init__(self, api_result): @@ -190,12 +190,20 @@ def json(self): class GraphQLTransportStub: def __init__(self): - self._query: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) + self._query: Callable[..., Any] | None = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No GraphQL stub configured")) + self._sequence: list[Any] | None = None self.calls: list[dict[str, Any]] = [] def stub(self, func: Callable[..., Any]) -> None: + self._sequence = None self._query = func + def stub_sequence(self, responses: list[Any]) -> None: + if not responses: + raise ValueError("response sequence cannot be empty") + self._query = None + self._sequence = list(responses) + def query(self, url: str, *, headers=None, query: str, variables=None, timeout_seconds=None): self.calls.append( { @@ -206,19 +214,43 @@ def query(self, url: str, *, headers=None, query: str, variables=None, timeout_s "timeout_seconds": timeout_seconds, } ) + if self._sequence is not None: + response = self._sequence[0] + if len(self._sequence) > 1: + del self._sequence[0] + if isinstance(response, Exception): + raise response + return response + assert self._query is not None return self._query(url=url, headers=headers, query=query, variables=variables, timeout_seconds=timeout_seconds) class ArtifactDownloadTransportStub: def __init__(self): - self._download: Callable[..., Any] = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) + self._download: Callable[..., Any] | None = lambda **kwargs: (_ for _ in ()).throw(AssertionError("No artifact download stub configured")) + self._sequence: list[Any] | None = None self.calls: list[dict[str, Any]] = [] def stub(self, func: Callable[..., Any]) -> None: + self._sequence = None self._download = func + def stub_sequence(self, responses: list[Any]) -> None: + if not responses: + raise ValueError("response sequence cannot be empty") + self._download = None + self._sequence = list(responses) + def download(self, url: str, *, headers=None, timeout_seconds=None): self.calls.append({"url": url, "headers": headers, "timeout_seconds": timeout_seconds}) + if self._sequence is not None: + response = self._sequence[0] + if len(self._sequence) > 1: + del self._sequence[0] + if isinstance(response, Exception): + raise response + return response + assert self._download is not None return self._download(url=url, headers=headers, timeout_seconds=timeout_seconds) diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index 53d149ba7..f7093b2f2 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -4,6 +4,10 @@ from tests.fixtures.fake_jitter import DeterministicJitter from tests.fixtures.fake_sleeper import RecordingSleeper from tests.fixtures.fake_uuid import FixedUuidSource +from tests.fixtures.focused_fake_services import ( + ArtifactDownloadTransportStub, + GraphQLTransportStub, +) from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result @@ -71,3 +75,27 @@ def test_recording_logger_captures_structured_events(): "fields": {"issue_number": 42, "retry_attempt": 2}, } ] + + +def test_graphql_transport_stub_replays_sequence_and_keeps_last_value(): + transport = GraphQLTransportStub() + transport.stub_sequence([{"data": {"viewer": {"login": "bot"}}}, {"data": {"viewer": {"login": "bot-2"}}}]) + + assert transport.query("https://api.github.com/graphql", query="q") == {"data": {"viewer": {"login": "bot"}}} + assert transport.query("https://api.github.com/graphql", query="q") == {"data": {"viewer": {"login": "bot-2"}}} + assert transport.query("https://api.github.com/graphql", query="q") == {"data": {"viewer": {"login": "bot-2"}}} + + +def test_artifact_download_transport_stub_replays_sequence_and_raises_exceptions(): + transport = ArtifactDownloadTransportStub() + transport.stub_sequence([RuntimeError("timeout"), {"ok": True}]) + + try: + transport.download("https://example.com/artifact.zip") + except RuntimeError as exc: + assert "timeout" in str(exc) + else: + raise AssertionError("expected RuntimeError") + + assert transport.download("https://example.com/artifact.zip") == {"ok": True} + assert transport.download("https://example.com/artifact.zip") == {"ok": True} diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index a09b80e47..dd7f74b50 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -82,14 +82,13 @@ def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): - responses = iter( + bot = _bot(monkeypatch) + bot.graphql_transport.stub_sequence( [ FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), ] ) - bot = _bot(monkeypatch) - bot.graphql_transport.stub(lambda **kwargs: next(responses)) result = github_api.github_graphql_request( bot, "query { viewer { login } }", @@ -131,7 +130,7 @@ def test_github_api_request_reports_retry_exhaustion_on_repeated_429(monkeypatch def test_github_graphql_request_reports_invalid_payload(monkeypatch): bot = _bot(monkeypatch) - bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, ValueError("bad json"), "bad json")) + bot.graphql_transport.stub_sequence([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) result = github_api.github_graphql_request(bot, "query { viewer { login } }") @@ -141,7 +140,7 @@ def test_github_graphql_request_reports_invalid_payload(monkeypatch): def test_github_graphql_request_reports_graphql_errors(monkeypatch): bot = _bot(monkeypatch) - bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")) + bot.graphql_transport.stub_sequence([FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")]) result = github_api.github_graphql_request(bot, "query { viewer { login } }") @@ -321,7 +320,7 @@ def test_github_api_request_logs_error_response_to_recording_logger(monkeypatch) def test_github_graphql_request_passes_timeout(monkeypatch): bot = _bot(monkeypatch) - bot.graphql_transport.stub(lambda **kwargs: FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")) + bot.graphql_transport.stub_sequence([FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok")]) result = github_api.github_graphql_request(bot, "query { viewer { login } }", timeout_seconds=9.5) diff --git a/tests/unit/reviewer_bot/test_sweeper_downloads.py b/tests/unit/reviewer_bot/test_sweeper_downloads.py index d1437c732..b05686c66 100644 --- a/tests/unit/reviewer_bot/test_sweeper_downloads.py +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -6,18 +6,6 @@ from tests.fixtures.reviewer_bot import make_zip_payload -def _stub_download_sequence(runtime, responses): - iterator = iter(responses) - - def fake_download(**kwargs): - response = next(iterator) - if isinstance(response, Exception): - raise response - return response - - runtime.artifact_download_transport.stub(fake_download) - - def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): payload = {"source_event_key": "issue_comment:100"} responses = iter([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")]) @@ -29,7 +17,7 @@ def test_download_artifact_payload_retries_429_then_succeeds(monkeypatch): runtime.get_github_token = lambda: "token" runtime.jitter = DeterministicJitter(0.5) runtime.sleeper = RecordingSleeper() - _stub_download_sequence(runtime, [*responses, success_response]) + runtime.artifact_download_transport.stub_sequence([*responses, success_response]) status, artifact_payload = sweeper._download_artifact_payload( runtime, @@ -57,7 +45,7 @@ def test_download_artifact_payload_reports_request_exception_unavailable(monkeyp runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" runtime.sleeper = RecordingSleeper() - _stub_download_sequence(runtime, [RuntimeError("timeout")]) + runtime.artifact_download_transport.stub_sequence([RuntimeError("timeout")]) status, payload = sweeper._download_artifact_payload( runtime, @@ -74,7 +62,7 @@ def test_download_artifact_payload_reports_retry_exhaustion_unavailable(monkeypa runtime.set_config_value("GITHUB_TOKEN", "token") runtime.get_github_token = lambda: "token" runtime.sleeper = RecordingSleeper() - _stub_download_sequence(runtime, [FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6) + runtime.artifact_download_transport.stub_sequence([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6) status, payload = sweeper._download_artifact_payload( runtime, From a97080c76b56c2e96b07ba1a36ee7491c6057891 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:40:25 +0900 Subject: [PATCH 191/209] test(reviewer-bot): converge transport fakes on shared authority --- tests/fixtures/focused_fake_services.py | 6 +++--- tests/unit/reviewer_bot/test_fixture_contracts.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index 09e7d946b..4dc9bf34b 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -133,8 +133,8 @@ def github_api_request(self, method: str, endpoint: str, data=None, extra_header class RestTransportStub: - def __init__(self, runtime): - self._runtime = runtime + def __init__(self, github_stub: GitHubStub): + self._github = github_stub self.calls: list[dict[str, Any]] = [] self._direct_request: Callable[..., Any] | None = None @@ -165,7 +165,7 @@ def request(self, method: str, url: str, *, headers=None, json_data=None, timeou endpoint = "/".join(parts[3:]) else: endpoint = parsed.path.lstrip("/") - result = self._runtime.github.github_api_request(method, endpoint, data=json_data) + result = self._github.github_api_request(method, endpoint, data=json_data) class _Response: def __init__(self, api_result): diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index f7093b2f2..9ed97b7e0 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -6,7 +6,9 @@ from tests.fixtures.fake_uuid import FixedUuidSource from tests.fixtures.focused_fake_services import ( ArtifactDownloadTransportStub, + GitHubStub, GraphQLTransportStub, + RestTransportStub, ) from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result @@ -86,6 +88,17 @@ def test_graphql_transport_stub_replays_sequence_and_keeps_last_value(): assert transport.query("https://api.github.com/graphql", query="q") == {"data": {"viewer": {"login": "bot-2"}}} +def test_rest_transport_stub_routes_repo_urls_through_github_stub(): + github = GitHubStub(RouteGitHubApi().add_request("GET", "issues/42", result=github_result(200, {"ok": True}))) + transport = RestTransportStub(github) + + response = transport.request("GET", "https://api.github.com/repos/rustfoundation/safety-critical-rust-coding-guidelines/issues/42") + + assert response.status_code == 200 + assert response.json() == {"ok": True} + assert transport.calls[0]["url"].endswith("issues/42") + + def test_artifact_download_transport_stub_replays_sequence_and_raises_exceptions(): transport = ArtifactDownloadTransportStub() transport.stub_sequence([RuntimeError("timeout"), {"ok": True}]) From ad3faa38a13eb8f447b5295cafe1a7f6f5b9fc72 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:41:20 +0900 Subject: [PATCH 192/209] test(reviewer-bot): wire fake runtime rest transport through github stub --- tests/fixtures/fake_runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 714ff6d35..c22aca24f 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -366,13 +366,13 @@ def __init__(self, monkeypatch, *, github=None): self.config = ConfigBag(monkeypatch) self.outputs = OutputCapture() self.deferred_payloads = DeferredPayloadStore() - self.rest_transport = RestTransportStub(self) + self.github = GitHubStub(github) + self.rest_transport = RestTransportStub(self.github) self.graphql_transport = GraphQLTransportStub() self.artifact_download_transport = ArtifactDownloadTransportStub() self.touch_tracker = TouchTrackerStub() self.state_store = StateStoreStub() self.locks = LockStub() - self.github = GitHubStub(github) self.workflow = WorkflowBehaviorStub() self._fetch_members = lambda: [] self.adapters = FakeRuntimeAdapterServices(self) From b302668692fef4f3500b5157ef37988d67cec34e Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:48:30 +0900 Subject: [PATCH 193/209] refactor(reviewer-bot): make state and lock seams strictly runtime-owned --- scripts/reviewer_bot_lib/context.py | 14 +---- scripts/reviewer_bot_lib/lease_lock.py | 68 ++++------------------ scripts/reviewer_bot_lib/state_store.py | 46 +++------------ tests/fixtures/fake_runtime.py | 6 ++ tests/unit/reviewer_bot/test_lease_lock.py | 7 +++ 5 files changed, 35 insertions(+), 106 deletions(-) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 575d1a3b1..4d53bad15 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -328,11 +328,6 @@ class StateStoreRuntimeContext(Protocol): """Minimal runtime surface required by state_store helpers.""" ACTIVE_LEASE_CONTEXT: LeaseContext | None - STATE_ISSUE_NUMBER: int - STATE_READ_RETRY_LIMIT: int - STATE_READ_RETRY_BASE_SECONDS: float - LOCK_API_RETRY_LIMIT: int - LOCK_RETRY_BASE_SECONDS: float GitHubApiResult: type[GitHubApiResult] logger: Logger sleeper: Sleeper @@ -365,6 +360,8 @@ def parse_iso8601_timestamp(self, value: Any) -> datetime | None: ... def normalize_lock_metadata(self, lock_meta: dict | None) -> dict: ... def ensure_state_issue_lease_lock_fresh(self) -> bool: ... def state_issue_number(self) -> int: ... + def state_read_retry_limit(self) -> int: ... + def state_read_retry_base_seconds(self) -> float: ... def lock_api_retry_limit(self) -> int: ... def lock_retry_base_seconds(self) -> float: ... @@ -383,13 +380,6 @@ class LeaseLockRuntimeContext(Protocol): """Minimal runtime surface required by lease_lock helpers.""" ACTIVE_LEASE_CONTEXT: LeaseContext | None - LOCK_API_RETRY_LIMIT: int - LOCK_RETRY_BASE_SECONDS: float - LOCK_LEASE_TTL_SECONDS: int - LOCK_MAX_WAIT_SECONDS: int - LOCK_RENEWAL_WINDOW_SECONDS: int - LOCK_REF_NAME: str - LOCK_REF_BOOTSTRAP_BRANCH: str LOCK_COMMIT_MARKER: str LOCK_SCHEMA_VERSION: int LeaseContext: type[LeaseContext] diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index d62a81db1..edcba068b 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -6,14 +6,8 @@ from . import retrying from .config import ( - LOCK_API_RETRY_LIMIT, LOCK_COMMIT_MARKER, - LOCK_LEASE_TTL_SECONDS, - LOCK_MAX_WAIT_SECONDS, - LOCK_REF_BOOTSTRAP_BRANCH, LOCK_REF_NAME, - LOCK_RENEWAL_WINDOW_SECONDS, - LOCK_RETRY_BASE_SECONDS, LeaseContext, ) from .context import LeaseLockContext, LeaseLockRuntimeContext @@ -24,18 +18,11 @@ def _log(bot: LeaseLockRuntimeContext, level: str, message: str, **fields: Any) def _sleep(bot: LeaseLockRuntimeContext, seconds: float) -> None: - sleeper = getattr(bot, "sleeper", None) - if sleeper is not None and hasattr(sleeper, "sleep"): - sleeper.sleep(seconds) - return - __import__("time").sleep(seconds) + bot.sleeper.sleep(seconds) def _jitter(bot: LeaseLockRuntimeContext, lower: float, upper: float) -> float: - jitter = getattr(bot, "jitter", None) - if jitter is not None and hasattr(jitter, "uniform"): - return jitter.uniform(lower, upper) - return __import__("random").uniform(lower, upper) + return bot.jitter.uniform(lower, upper) def _retry_delay(bot: LeaseLockRuntimeContext, base_seconds: float, retry_attempt: int) -> float: @@ -51,61 +38,35 @@ def uniform(self, lower: float, upper: float) -> float: def _now(bot: LeaseLockRuntimeContext) -> datetime: - clock = getattr(bot, "clock", None) - if clock is not None and hasattr(clock, "now"): - return clock.now() - datetime_module = getattr(bot, "datetime", datetime) - timezone_module = getattr(bot, "timezone", timezone) - return datetime_module.now(timezone_module.utc) + return bot.clock.now() def _monotonic(bot: LeaseLockRuntimeContext) -> float: - time_module = getattr(bot, "time", None) - if time_module is not None and hasattr(time_module, "monotonic"): - return time_module.monotonic() - return __import__("time").monotonic() + return bot.time.monotonic() def _uuid4_hex(bot: LeaseLockRuntimeContext) -> str: - source = getattr(bot, "uuid_source", None) - if source is not None and hasattr(source, "uuid4_hex"): - return source.uuid4_hex() - return __import__("uuid").uuid4().hex + return bot.uuid_source.uuid4_hex() def _lock_lease_ttl_seconds(bot: LeaseLockRuntimeContext) -> int: - accessor = getattr(bot, "lock_lease_ttl_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_LEASE_TTL_SECONDS", LOCK_LEASE_TTL_SECONDS) + return bot.lock_lease_ttl_seconds() def _lock_api_retry_limit(bot: LeaseLockRuntimeContext) -> int: - accessor = getattr(bot, "lock_api_retry_limit", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) + return bot.lock_api_retry_limit() def _lock_retry_base_seconds(bot: LeaseLockRuntimeContext) -> float: - accessor = getattr(bot, "lock_retry_base_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + return bot.lock_retry_base_seconds() def _lock_max_wait_seconds(bot: LeaseLockRuntimeContext) -> int: - accessor = getattr(bot, "lock_max_wait_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_MAX_WAIT_SECONDS", LOCK_MAX_WAIT_SECONDS) + return bot.lock_max_wait_seconds() def _lock_renewal_window_seconds(bot: LeaseLockRuntimeContext) -> int: - accessor = getattr(bot, "lock_renewal_window_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_RENEWAL_WINDOW_SECONDS", LOCK_RENEWAL_WINDOW_SECONDS) + return bot.lock_renewal_window_seconds() def lock_is_currently_valid(bot: LeaseLockContext, lock_meta: dict, now: datetime | None = None) -> bool: @@ -173,9 +134,7 @@ def normalize_lock_ref_name(ref_name: str) -> str: def get_lock_ref_name(bot: LeaseLockContext) -> str: - if callable(getattr(bot, "lock_ref_name", None)): - return normalize_lock_ref_name(bot.lock_ref_name()) - return normalize_lock_ref_name(getattr(bot, "LOCK_REF_NAME", LOCK_REF_NAME)) + return normalize_lock_ref_name(bot.lock_ref_name()) def get_lock_ref_display(bot: LeaseLockContext) -> str: @@ -305,10 +264,7 @@ def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: f"{get_lock_ref_display(bot)} (status {response.status_code}): {response.text}" ) - if callable(getattr(bot, "lock_ref_bootstrap_branch", None)): - default_branch = bot.lock_ref_bootstrap_branch() - else: - default_branch = getattr(bot, "LOCK_REF_BOOTSTRAP_BRANCH", LOCK_REF_BOOTSTRAP_BRANCH) + default_branch = bot.lock_ref_bootstrap_branch() branch_response = bot.github_api_request( "GET", f"git/ref/heads/{default_branch}", diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 5204d6164..76c99e969 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -10,17 +10,12 @@ from . import retrying from .config import ( FRESHNESS_RUNTIME_EPOCH_LEGACY, - LOCK_API_RETRY_LIMIT, LOCK_BLOCK_END_MARKER, LOCK_BLOCK_START_MARKER, LOCK_METADATA_KEYS, - LOCK_RETRY_BASE_SECONDS, LOCK_SCHEMA_VERSION, STATE_BLOCK_END_MARKER, STATE_BLOCK_START_MARKER, - STATE_ISSUE_NUMBER, - STATE_READ_RETRY_BASE_SECONDS, - STATE_READ_RETRY_LIMIT, STATE_SCHEMA_VERSION, StateIssueBodyParts, StateIssueSnapshot, @@ -33,18 +28,11 @@ def _log(bot: StateStoreRuntimeContext, level: str, message: str, **fields: Any) def _sleep(bot: StateStoreRuntimeContext, seconds: float) -> None: - sleeper = getattr(bot, "sleeper", None) - if sleeper is not None and hasattr(sleeper, "sleep"): - sleeper.sleep(seconds) - return - __import__("time").sleep(seconds) + bot.sleeper.sleep(seconds) def _jitter(bot: StateStoreRuntimeContext, lower: float, upper: float) -> float: - jitter = getattr(bot, "jitter", None) - if jitter is not None and hasattr(jitter, "uniform"): - return jitter.uniform(lower, upper) - return __import__("random").uniform(lower, upper) + return bot.jitter.uniform(lower, upper) def _retry_delay(bot: StateStoreRuntimeContext, base_seconds: float, retry_attempt: int) -> float: @@ -60,45 +48,27 @@ def uniform(self, lower: float, upper: float) -> float: def _now_iso(bot: StateStoreRuntimeContext) -> str: - clock = getattr(bot, "clock", None) - if clock is not None and hasattr(clock, "now"): - return clock.now().isoformat() - return datetime.now(timezone.utc).isoformat() + return bot.clock.now().isoformat() def _state_issue_number(bot: StateStoreRuntimeContext) -> int: - accessor = getattr(bot, "state_issue_number", None) - if callable(accessor): - return accessor() - return getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) + return bot.state_issue_number() def _lock_api_retry_limit(bot: StateStoreRuntimeContext) -> int: - accessor = getattr(bot, "lock_api_retry_limit", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) + return bot.lock_api_retry_limit() def _lock_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: - accessor = getattr(bot, "lock_retry_base_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + return bot.lock_retry_base_seconds() def _state_read_retry_limit(bot: StateStoreRuntimeContext) -> int: - accessor = getattr(bot, "state_read_retry_limit", None) - if callable(accessor): - return accessor() - return getattr(bot, "STATE_READ_RETRY_LIMIT", STATE_READ_RETRY_LIMIT) + return bot.state_read_retry_limit() def _state_read_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: - accessor = getattr(bot, "state_read_retry_base_seconds", None) - if callable(accessor): - return accessor() - return getattr(bot, "STATE_READ_RETRY_BASE_SECONDS", STATE_READ_RETRY_BASE_SECONDS) + return bot.state_read_retry_base_seconds() def get_state_issue(bot: StateStoreContext) -> dict | None: diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index c22aca24f..25c0f5409 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -431,6 +431,12 @@ def get_github_token(self) -> str: def state_issue_number(self) -> int: return int(self.get_config_value("STATE_ISSUE_NUMBER", "0") or 0) + def state_read_retry_limit(self) -> int: + return int(self.get_config_value("REVIEWER_BOT_STATE_READ_RETRY_LIMIT", "4") or 0) + + def state_read_retry_base_seconds(self) -> float: + return float(self.get_config_value("REVIEWER_BOT_STATE_READ_RETRY_SECONDS", "1.0") or 0.0) + def lock_api_retry_limit(self) -> int: return int(self.get_config_value("REVIEWER_BOT_LOCK_API_RETRY_LIMIT", "5") or 0) diff --git a/tests/unit/reviewer_bot/test_lease_lock.py b/tests/unit/reviewer_bot/test_lease_lock.py index a8a492e5f..602c53a28 100644 --- a/tests/unit/reviewer_bot/test_lease_lock.py +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -45,6 +45,13 @@ def _lease_bot(**overrides): config_values.update(overrides["config_values"]) delattr(bot, "config_values") bot.clear_lock_metadata = lambda: lease_lock.clear_lock_metadata(bot) + bot.lock_api_retry_limit = lambda: bot.LOCK_API_RETRY_LIMIT + bot.lock_retry_base_seconds = lambda: bot.LOCK_RETRY_BASE_SECONDS + bot.lock_lease_ttl_seconds = lambda: getattr(bot, "LOCK_LEASE_TTL_SECONDS", 300) + bot.lock_max_wait_seconds = lambda: bot.LOCK_MAX_WAIT_SECONDS + bot.lock_renewal_window_seconds = lambda: bot.LOCK_RENEWAL_WINDOW_SECONDS + bot.lock_ref_name = lambda: bot.LOCK_REF_NAME + bot.lock_ref_bootstrap_branch = lambda: bot.LOCK_REF_BOOTSTRAP_BRANCH bot.get_state_issue_html_url = lambda: lease_lock.get_state_issue_html_url(bot) bot.get_lock_ref_display = lambda: lease_lock.get_lock_ref_display(bot) bot.get_lock_ref_snapshot = lambda: lease_lock.get_lock_ref_snapshot(bot) From 0ad2cbda43fe39e38b326579fe816cda7479bb14 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 09:58:48 +0900 Subject: [PATCH 194/209] test(reviewer-bot): remove ambient env mirroring from fake config and harnesses --- .../reviewer_bot/test_fake_runtime_contract.py | 11 +++++++++++ tests/fixtures/focused_fake_services.py | 1 - tests/fixtures/reviewer_bot_env.py | 12 +++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 347624ac2..8282ce77d 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -26,6 +26,17 @@ def test_fake_runtime_config_writes_round_trip_locally(monkeypatch): runtime.set_config_value("EVENT_NAME", "issue_comment") assert runtime.get_config_value("EVENT_NAME") == "issue_comment" + + +def test_fake_runtime_config_writes_do_not_leak_to_process_env(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + monkeypatch.delenv("EVENT_NAME", raising=False) + + runtime.set_config_value("EVENT_NAME", "issue_comment") + + import os + + assert "EVENT_NAME" not in os.environ assert hasattr(FakeReviewerBotRuntime, "__getattr__") is False assert "_module" not in vars(runtime) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index 4dc9bf34b..dc80894d8 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -17,7 +17,6 @@ def get(self, name: str, default: str = "") -> str: def set(self, name: str, value) -> None: rendered = str(value) self.values[name] = rendered - self._monkeypatch.setenv(name, rendered) class OutputCapture: diff --git a/tests/fixtures/reviewer_bot_env.py b/tests/fixtures/reviewer_bot_env.py index aeaff5407..0a00d9124 100644 --- a/tests/fixtures/reviewer_bot_env.py +++ b/tests/fixtures/reviewer_bot_env.py @@ -47,13 +47,11 @@ def clear_reviewer_bot_env(monkeypatch) -> None: monkeypatch.delenv(name, raising=False) -def reset_reviewer_bot_process_state(monkeypatch, target_module) -> None: - clear_reviewer_bot_env(monkeypatch) - monkeypatch.setattr(target_module, "ACTIVE_LEASE_CONTEXT", build_test_lease_context()) - monkeypatch.setattr(target_module, "TOUCHED_ISSUE_NUMBERS", set()) - monkeypatch.setattr(target_module, "_reviewer_board_project_metadata", None, raising=False) - - def set_env_values(config, **values) -> None: for name, value in values.items(): config.set(name, value) + + +def set_process_env_values(monkeypatch, **values) -> None: + for name, value in values.items(): + monkeypatch.setenv(name, str(value)) From 6a49fb4bbc7ecd7f38c0fdb5c7923f81c71f46f2 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 10:03:12 +0900 Subject: [PATCH 195/209] test(reviewer-bot): restore review-state compatibility marker --- scripts/reviewer_bot_lib/runtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 5e36e1df7..da7f15878 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -431,6 +431,7 @@ def get_issue_or_pr_snapshot(self, issue_number: int): def get_pull_request_reviews(self, issue_number: int): return self.adapters.github.get_pull_request_reviews(issue_number) + # Adapter-only mutable review-state compatibility surface. def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): return self.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) From 9a188ff055f5830a60c68ea86123e8be442650c5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 12:16:14 +0900 Subject: [PATCH 196/209] refactor(reviewer-bot): remove broad runtime github facade --- scripts/reviewer_bot_lib/automation.py | 2 +- scripts/reviewer_bot_lib/commands.py | 32 ++-- .../reviewer_bot_lib/comment_application.py | 14 +- scripts/reviewer_bot_lib/context.py | 2 +- scripts/reviewer_bot_lib/lifecycle.py | 16 +- scripts/reviewer_bot_lib/maintenance.py | 6 +- scripts/reviewer_bot_lib/overdue.py | 4 +- scripts/reviewer_bot_lib/project_board.py | 4 +- scripts/reviewer_bot_lib/reconcile.py | 4 +- scripts/reviewer_bot_lib/reviews.py | 20 +-- scripts/reviewer_bot_lib/runtime.py | 48 ----- scripts/reviewer_bot_lib/sweeper.py | 4 +- .../reviewer_bot/test_adapter_contract.py | 4 +- tests/fixtures/fake_runtime.py | 1 + tests/fixtures/focused_fake_services.py | 164 ++++++++++++++++++ tests/unit/reviewer_bot/test_github_api.py | 16 +- .../reviewer_bot/test_reviews_projection.py | 9 +- 17 files changed, 235 insertions(+), 115 deletions(-) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 68ec55531..0f21873a2 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -136,7 +136,7 @@ def handle_accept_no_fls_changes_command( labels = list(privileged_request.issue_labels) if bot.FLS_AUDIT_LABEL not in labels: return "❌ This command is only available on issues labeled `fls-audit`.", False - permission_status = bot.get_user_permission_status(comment_author, "triage") + permission_status = bot.github.get_user_permission_status(comment_author, "triage") if permission_status == "unavailable": return "❌ Unable to verify triage permissions right now; refusing to run this command.", False if permission_status != "granted": diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 399e87d71..f962e60fc 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -132,15 +132,15 @@ def _apply_assignment_side_effects( assignment_method: str, ) -> tuple[AssignmentAttempt, str | None]: issue_number = request.issue_number - assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) + assignment_attempt = bot.github.request_reviewer_assignment(issue_number, reviewer) review_state.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) bot.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") - failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) + failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: - bot.post_comment(issue_number, failure_comment) + bot.github.post_comment(issue_number, failure_comment) if assignment_attempt.success: if request.is_pull_request: - bot.post_comment(issue_number, get_pr_guidance(reviewer, request.issue_author)) + bot.github.post_comment(issue_number, get_pr_guidance(reviewer, request.issue_author)) else: labels = set(request.issue_labels) guidance = ( @@ -148,12 +148,12 @@ def _apply_assignment_side_effects( if bot.FLS_AUDIT_LABEL in labels else get_issue_guidance(reviewer, request.issue_author) ) - bot.post_comment(issue_number, guidance) + bot.github.post_comment(issue_number, guidance) return assignment_attempt, failure_comment def _current_assignees_or_error(bot, issue_number: int) -> tuple[list[str] | None, str | None]: - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees = bot.github.get_issue_assignees(issue_number) if current_assignees is None: return None, "❌ Unable to determine current assignees/reviewers from GitHub; refusing to continue." return current_assignees, None @@ -191,7 +191,7 @@ def handle_pass_command( if not next_reviewer: return ("❌ No other reviewers available. Everyone in the queue has either passed on this issue or is the author."), False bot.reposition_member_as_next(state, passed_reviewer) - bot.unassign_reviewer(issue_number, passed_reviewer) + bot.github.unassign_reviewer(issue_number, passed_reviewer) assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, @@ -267,7 +267,7 @@ def handle_pass_until_command( is_current_reviewer = ((tracked_reviewer and tracked_reviewer.lower() == comment_author.lower()) or comment_author.lower() in [a.lower() for a in current_assignees]) reassigned_msg = "" if is_current_reviewer: - bot.unassign_reviewer(issue_number, comment_author) + bot.github.unassign_reviewer(issue_number, comment_author) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) if next_reviewer: @@ -306,7 +306,7 @@ def handle_label_command( matches = re.findall(pattern, label_string) if not matches: return "❌ No valid labels found. Use `+label-name` to add or `-label-name` to remove.", False, False - existing_labels = bot.get_repo_labels() + existing_labels = bot.github.get_repo_labels() results = [] all_success = True state_changed = False @@ -318,7 +318,7 @@ def handle_label_command( if label not in existing_labels: results.append(f"⚠️ Label `{label}` does not exist in this repository") all_success = False - elif bot.add_label(issue_number, label): + elif bot.github.add_label(issue_number, label): results.append(f"✅ Added label `{label}`") if label == "sign-off: create pr" and not assignment_request.is_pull_request: review_data = review_state.ensure_review_entry(state, issue_number) @@ -331,7 +331,7 @@ def handle_label_command( results.append(f"❌ Failed to add label `{label}`") all_success = False elif action == "-": - if bot.remove_label(issue_number, label): + if bot.github.remove_label(issue_number, label): results.append(f"✅ Removed label `{label}`") else: results.append(f"❌ Failed to remove label `{label}`") @@ -456,7 +456,7 @@ def handle_claim_command( if assignee_error: return assignee_error, False for assignee in current_assignees: - bot.unassign_reviewer(issue_number, assignee) + bot.github.unassign_reviewer(issue_number, assignee) assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, @@ -489,7 +489,7 @@ def handle_release_command( target_username = args[0].lstrip("@") reason = " ".join(args[1:]) if len(args) > 1 else None releasing_other = target_username.lower() != comment_author.lower() - permission_status = bot.get_user_permission_status(comment_author, "triage") + permission_status = bot.github.get_user_permission_status(comment_author, "triage") if permission_status == "unavailable": return "❌ Unable to verify triage permissions right now; refusing to continue.", False if releasing_other and permission_status != "granted": @@ -522,7 +522,7 @@ def handle_release_command( if current_assignees: return (f"❌ @{comment_author} is not assigned to this issue/PR. Current assignee(s): @{', @'.join(current_assignees)}"), False return "❌ No reviewer is currently assigned to release.", False - bot.unassign_reviewer(issue_number, target_username) + bot.github.unassign_reviewer(issue_number, target_username) if "active_reviews" in state and issue_key in state["active_reviews"] and isinstance(state["active_reviews"][issue_key], dict): state["active_reviews"][issue_key]["current_reviewer"] = None if assignment_method == "round-robin": @@ -557,7 +557,7 @@ def handle_assign_command( if assignee_error: return assignee_error, False for assignee in current_assignees: - bot.unassign_reviewer(issue_number, assignee) + bot.github.unassign_reviewer(issue_number, assignee) assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, state, @@ -585,7 +585,7 @@ def handle_assign_from_queue_command( if assignee_error: return assignee_error, False for assignee in current_assignees: - bot.unassign_reviewer(issue_number, assignee) + bot.github.unassign_reviewer(issue_number, assignee) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: diff --git a/scripts/reviewer_bot_lib/comment_application.py b/scripts/reviewer_bot_lib/comment_application.py index 60f290793..578dd83c0 100644 --- a/scripts/reviewer_bot_lib/comment_application.py +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -104,7 +104,7 @@ def validate_accept_no_fls_changes_handoff( labels = commands_module.parse_issue_labels(bot) if bot.FLS_AUDIT_LABEL not in labels: return False, {"reason": "missing_fls_audit_label"} - permission_status = bot.get_user_permission_status(request.comment_author, "triage") + permission_status = bot.github.get_user_permission_status(request.comment_author, "triage") if permission_status == "unavailable": return False, {"reason": "authorization_unavailable"} if permission_status != "granted": @@ -144,7 +144,7 @@ def apply_comment_command( if command == "accept-no-fls-changes": is_valid, metadata = validate_accept_no_fls_changes_handoff(bot, request) if not is_valid: - bot.post_comment( + bot.github.post_comment( issue_number, "❌ This command is not eligible for privileged handoff from the current trusted live state.", ) @@ -152,13 +152,13 @@ def apply_comment_command( stored = store_pending_privileged_command(review_data, issue_number, source_event_key, command, comment_author, list(args)) if stored: review_data["pending_privileged_commands"][source_event_key].update(metadata) - bot.post_comment( + bot.github.post_comment( issue_number, "✅ Recorded pending privileged command `accept-no-fls-changes` from trusted live validation. Use the isolated privileged workflow to execute it from issue `#314` state.", ) return stored if command == "_multiple_commands": - bot.post_comment(issue_number, f"⚠️ Multiple bot commands in one comment are ignored. Please post a single command per comment. For a list of commands, use `{bot.BOT_MENTION} /commands`.") + bot.github.post_comment(issue_number, f"⚠️ Multiple bot commands in one comment are ignored. Please post a single command per comment. For a list of commands, use `{bot.BOT_MENTION} /commands`.") return False response = "" success = False @@ -255,11 +255,11 @@ def apply_comment_command( response = f"❌ Unknown command: `/{command}`\n\nAvailable commands:\n{config_module.get_commands_help()}" comment_id = request.comment_id if comment_id > 0 and command != "_multiple_commands": - bot.add_reaction(comment_id, "eyes") + bot.github.add_reaction(comment_id, "eyes") if success: - bot.add_reaction(comment_id, "+1") + bot.github.add_reaction(comment_id, "+1") if response: - bot.post_comment(issue_number, response) + bot.github.post_comment(issue_number, response) return state_changed diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 4d53bad15..8b1d32740 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -233,7 +233,7 @@ def github_graphql( @runtime_checkable class ProjectBoardProjectionContext(Protocol): - def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: ... + github: Any def compute_reviewer_response_state( self, diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 409668b98..36ae20dcb 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -64,7 +64,7 @@ def handle_transition_notice(bot, state: dict, issue_number: int, reviewer: str) You may still continue this review, or use `{bot.BOT_MENTION} /pass`, `{bot.BOT_MENTION} /release`, or `{bot.BOT_MENTION} /away` if you need to step back. _If you believe this is in error or have extenuating circumstances, please reach out to the subcommittee._""" - if not bot.post_comment(issue_number, notice_message): + if not bot.github.post_comment(issue_number, notice_message): return False record_transition_notice_sent( review_data, @@ -90,7 +90,7 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: tracked_reviewer = review_data.get("current_reviewer") if tracked_reviewer: return False - current_assignees = bot.get_issue_assignees(issue_number) + current_assignees = bot.github.get_issue_assignees(issue_number) if current_assignees is None: raise RuntimeError(f"Unable to determine assignees for #{issue_number}") if current_assignees: @@ -101,10 +101,10 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: issue_author = request.issue_author reviewer = bot.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) if not reviewer: - bot.post_comment(issue_number, f"⚠️ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue.") + bot.github.post_comment(issue_number, f"⚠️ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue.") return False is_pr = request.is_pull_request - assignment_attempt = bot.request_reviewer_assignment(issue_number, reviewer) + assignment_attempt = bot.github.request_reviewer_assignment(issue_number, reviewer) set_current_reviewer(state, issue_number, reviewer) review_data = ensure_review_entry(state, issue_number, create=True) if is_pr and isinstance(review_data, dict): @@ -112,14 +112,14 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: if head_sha: review_data["active_head_sha"] = head_sha bot.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") - failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) + failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: - bot.post_comment(issue_number, failure_comment) + bot.github.post_comment(issue_number, failure_comment) if is_pr and assignment_attempt.success: - bot.post_comment(issue_number, get_pr_guidance(reviewer, issue_author)) + bot.github.post_comment(issue_number, get_pr_guidance(reviewer, issue_author)) if not is_pr: guidance = get_fls_audit_guidance(reviewer, issue_author) if bot.FLS_AUDIT_LABEL in labels else get_issue_guidance(reviewer, issue_author) - bot.post_comment(issue_number, guidance) + bot.github.post_comment(issue_number, guidance) return True diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index fbf7401c4..72106ec58 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -186,7 +186,7 @@ def handle_manual_dispatch(bot, state: dict) -> bool: record["completed_at"] = _now_iso(bot) record["result"] = "unsupported_command" return True - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict) or isinstance(issue_snapshot.get("pull_request"), dict): record["status"] = "failed_closed" record["completed_at"] = _now_iso(bot) @@ -199,7 +199,7 @@ def handle_manual_dispatch(bot, state: dict) -> bool: name = label.get("name") if isinstance(name, str): labels.add(name) - permission_status = bot.get_user_permission_status(actor, "triage") + permission_status = bot.github.get_user_permission_status(actor, "triage") if bot.FLS_AUDIT_LABEL not in labels: record["status"] = "failed_closed" record["completed_at"] = _now_iso(bot) @@ -240,7 +240,7 @@ def handle_scheduled_check(bot, state: dict) -> bool: if not isinstance(review_data, dict) or not review_data.get("current_reviewer"): continue issue_number = int(issue_key) - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict) or not isinstance(issue_snapshot.get("pull_request"), dict): continue try: diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index 8c5e83197..b374789d6 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -30,7 +30,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: continue issue_number = int(issue_key) - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): _log(bot, "warning", f"Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable", issue_number=issue_number) continue @@ -173,7 +173,7 @@ def handle_overdue_review_warning(bot, state: dict, issue_number: int, reviewer: _Life happens! If you're dealing with something, just let us know._""" - if not bot.post_comment(issue_number, warning_message): + if not bot.github.post_comment(issue_number, warning_message): return False now = bot.datetime.now(bot.timezone.utc).isoformat() diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index bf8fe0a7f..3a95e3d5f 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -286,7 +286,7 @@ def build_board_projection_input( issue_snapshot: dict[str, Any] | None = None, ) -> BoardProjectionInput: if issue_snapshot is None: - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): raise RuntimeError(f"Unable to load issue or PR snapshot for #{issue_number}") @@ -371,7 +371,7 @@ def derive_board_projection(input: BoardProjectionInput) -> BoardProjectionValue def preview_board_projection_for_item(bot, state: dict, issue_number: int) -> BoardPreviewResult: - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) input = build_board_projection_input(bot, state, issue_number, issue_snapshot=issue_snapshot) desired = derive_board_projection(input) return BoardPreviewResult( diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 3b1acc5a1..77deb65f8 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -234,7 +234,7 @@ def _read_optional_reconcile_object(bot, endpoint: str, *, label: str) -> dict | def _read_reconcile_reviews(bot, issue_number: int) -> list[dict]: - reviews = bot.get_pull_request_reviews(issue_number) + reviews = bot.github.get_pull_request_reviews(issue_number) if reviews is None: raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") if not isinstance(reviews, list): @@ -381,7 +381,7 @@ def handle_rectify_command(bot, state: dict, issue_number: int, comment_author: triage_status = "denied" if not is_current_reviewer: - triage_status = bot.get_user_permission_status(comment_author, "triage") + triage_status = bot.github.get_user_permission_status(comment_author, "triage") if not is_current_reviewer and triage_status == "unavailable": return ( diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 557ef3295..95f7b408d 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -116,7 +116,7 @@ def get_pull_request_reviews_result(bot, issue_number: int, reviews: list[dict] def _permission_status(bot, username: str, permission: str) -> str: - status = bot.get_user_permission_status(username, permission) + status = bot.github.get_user_permission_status(username, permission) if status not in {"granted", "denied", "unavailable"}: return "unavailable" return status @@ -163,7 +163,7 @@ def get_latest_valid_current_reviewer_review_for_cycle( if boundary is None: return None if reviews is None: - reviews = bot.get_pull_request_reviews(issue_number) + reviews = bot.github.get_pull_request_reviews(issue_number) if reviews is None: return None latest_review = None @@ -205,7 +205,7 @@ def get_valid_current_reviewer_reviews_for_cycle( if boundary is None: return [] if reviews is None: - reviews = bot.get_pull_request_reviews(issue_number) + reviews = bot.github.get_pull_request_reviews(issue_number) if reviews is None: return [] valid_reviews: list[dict] = [] @@ -740,7 +740,7 @@ def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) - review_data["mandatory_approver_satisfied_by"] = None review_data["mandatory_approver_satisfied_at"] = None state_changed = True - if bot.ensure_label_exists(MANDATORY_TRIAGE_APPROVER_LABEL): + if bot.github.ensure_label_exists(MANDATORY_TRIAGE_APPROVER_LABEL): try: if bot.add_label_with_status(issue_number, MANDATORY_TRIAGE_APPROVER_LABEL): if review_data.get("mandatory_approver_label_applied_at") is None: @@ -749,7 +749,7 @@ def trigger_mandatory_approver_escalation(bot, state: dict, issue_number: int) - except RuntimeError as exc: _log(bot, "warning", f"Unable to apply escalation label on #{issue_number}: {exc}", issue_number=issue_number, error=str(exc)) if review_data.get("mandatory_approver_pinged_at") is None: - if bot.post_comment(issue_number, MANDATORY_TRIAGE_ESCALATION_TEMPLATE): + if bot.github.post_comment(issue_number, MANDATORY_TRIAGE_ESCALATION_TEMPLATE): review_data["mandatory_approver_pinged_at"] = now state_changed = True return state_changed @@ -769,7 +769,7 @@ def satisfy_mandatory_approver_requirement(bot, state: dict, issue_number: int, bot.remove_label_with_status(issue_number, MANDATORY_TRIAGE_APPROVER_LABEL) except RuntimeError as exc: _log(bot, "warning", f"Unable to remove escalation label on #{issue_number}: {exc}", issue_number=issue_number, error=str(exc)) - bot.post_comment(issue_number, MANDATORY_TRIAGE_SATISFIED_TEMPLATE.format(approver=approver)) + bot.github.post_comment(issue_number, MANDATORY_TRIAGE_SATISFIED_TEMPLATE.format(approver=approver)) return True @@ -927,7 +927,7 @@ def compute_reviewer_response_state( reviews: list[dict] | None = None, ) -> dict[str, object]: if issue_snapshot is None: - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): return {"state": "projection_failed", "reason": "issue_snapshot_unavailable"} is_pr = isinstance(issue_snapshot.get("pull_request"), dict) @@ -1109,7 +1109,7 @@ def project_status_labels_for_item( issue_snapshot: dict | None = None, ) -> tuple[set[str] | None, dict[str, str | None]]: if issue_snapshot is None: - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): return None, {"state": "projection_failed", "reason": "issue_snapshot_unavailable"} if str(issue_snapshot.get("state", "")).lower() == "closed": @@ -1135,7 +1135,7 @@ def sync_status_labels(bot, issue_number: int, desired_labels: set[str], actual_ if not to_add and not to_remove: return False for label in STATUS_LABELS: - if not bot.ensure_label_exists(label): + if not bot.github.ensure_label_exists(label): raise RuntimeError(f"Unable to ensure reviewer-bot status label exists: {label}") changed = False for label in sorted(to_remove): @@ -1152,7 +1152,7 @@ def sync_status_labels(bot, issue_number: int, desired_labels: set[str], actual_ def sync_status_labels_for_items(bot, state: dict, issue_numbers: Iterable[int]) -> bool: changed = False for issue_number in sorted({n for n in issue_numbers if isinstance(n, int) and n > 0}): - issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number) desired_labels, metadata = bot.project_status_labels_for_item(issue_number, state, issue_snapshot=issue_snapshot) if desired_labels is None: reason = metadata.get("reason") if isinstance(metadata, dict) else "unknown" diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index da7f15878..46dde70cf 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -383,54 +383,6 @@ def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: def github_graphql(self, query: str, variables=None, *, token=None): return self.adapters.github.github_graphql(query, variables, token=token) - def post_comment(self, issue_number: int, body: str) -> bool: - return self.adapters.github.post_comment(issue_number, body) - - def get_repo_labels(self): - return self.adapters.github.get_repo_labels() - - def add_label(self, issue_number: int, label: str) -> bool: - return self.adapters.github.add_label(issue_number, label) - - def remove_label(self, issue_number: int, label: str) -> bool: - return self.adapters.github.remove_label(issue_number, label) - - def ensure_label_exists(self, label: str, *, color: str | None = None, description: str | None = None) -> bool: - return self.adapters.github.ensure_label_exists(label, color=color, description=description) - - def get_issue_assignees(self, issue_number: int): - return self.adapters.github.get_issue_assignees(issue_number) - - def request_reviewer_assignment(self, issue_number: int, username: str): - return self.adapters.github.request_reviewer_assignment(issue_number, username) - - def get_assignment_failure_comment(self, reviewer: str, attempt): - return self.adapters.github.get_assignment_failure_comment(reviewer, attempt) - - def add_reaction(self, comment_id: int, reaction: str) -> bool: - return self.adapters.github.add_reaction(comment_id, reaction) - - def remove_assignee(self, issue_number: int, username: str) -> bool: - return self.adapters.github.remove_assignee(issue_number, username) - - def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: - return self.adapters.github.remove_pr_reviewer(issue_number, username) - - def unassign_reviewer(self, issue_number: int, username: str) -> bool: - return self.adapters.github.unassign_reviewer(issue_number, username) - - def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: - return self.adapters.github.get_user_permission_status(username, required_permission) - - def check_user_permission(self, username: str, required_permission: str = "triage"): - return self.adapters.github.check_user_permission(username, required_permission) - - def get_issue_or_pr_snapshot(self, issue_number: int): - return self.adapters.github.get_issue_or_pr_snapshot(issue_number) - - def get_pull_request_reviews(self, issue_number: int): - return self.adapters.github.get_pull_request_reviews(issue_number) - # Adapter-only mutable review-state compatibility surface. def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): return self.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index da39df0ac..f2f3432ce 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -651,7 +651,7 @@ def _discover_visible_comment_events(bot, issue_number: int, review_data: dict) def _discover_visible_review_events(bot, issue_number: int, review_data: dict) -> tuple[list[dict] | None, bool]: watermark = _load_surface_watermark(review_data, "reviews_submitted") watermark["last_scan_started_at"] = _now_iso() - reviews = bot.get_pull_request_reviews(issue_number) + reviews = bot.github.get_pull_request_reviews(issue_number) if reviews is None: return None, False floor = _surface_scan_floor(bot, watermark) @@ -718,7 +718,7 @@ def _discover_visible_review_comment_events(bot, issue_number: int, review_data: def _discover_visible_review_dismissal_events(bot, issue_number: int, review_data: dict) -> tuple[list[dict] | None, bool]: watermark = _load_surface_watermark(review_data, "reviews_dismissed") watermark["last_scan_started_at"] = _now_iso() - reviews = bot.get_pull_request_reviews(issue_number) + reviews = bot.github.get_pull_request_reviews(issue_number) if reviews is None: return None, False floor = _surface_scan_floor(bot, watermark) diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 86e9f481f..2abc93718 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -175,10 +175,10 @@ def fake_request(method, endpoint, data=None, suppress_error_log=True, **kwargs) runtime.github_api_request = fake_request runtime.set_config_value("IS_PULL_REQUEST", "true") - github_pr_attempt = reviewer_bot._runtime_bot(runtime).request_reviewer_assignment(42, "alice") + github_pr_attempt = reviewer_bot._runtime_bot(runtime).github.request_reviewer_assignment(42, "alice") runtime.set_config_value("IS_PULL_REQUEST", "false") - github_issue_attempt = reviewer_bot._runtime_bot(runtime).request_reviewer_assignment(42, "alice") + github_issue_attempt = reviewer_bot._runtime_bot(runtime).github.request_reviewer_assignment(42, "alice") assert github_pr_attempt.success is True assert github_issue_attempt.success is True diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 25c0f5409..f6420213b 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -367,6 +367,7 @@ def __init__(self, monkeypatch, *, github=None): self.outputs = OutputCapture() self.deferred_payloads = DeferredPayloadStore() self.github = GitHubStub(github) + self.github.bind_runtime(self) self.rest_transport = RestTransportStub(self.github) self.graphql_transport = GraphQLTransportStub() self.artifact_download_transport = ArtifactDownloadTransportStub() diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index dc80894d8..bfd9c8fc6 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -110,6 +110,10 @@ def refresh(self) -> bool: class GitHubStub: def __init__(self, github=None): self._github = github + self._runtime = None + + def bind_runtime(self, runtime) -> None: + self._runtime = runtime def stub(self, github) -> None: self._github = github @@ -130,6 +134,166 @@ def github_api_request(self, method: str, endpoint: str, data=None, extra_header **kwargs, ) + def _runtime_required(self): + if self._runtime is None: + raise AssertionError("GitHubStub runtime not bound") + return self._runtime + + def _instance_override(self, name: str): + runtime = self._runtime_required() + override = runtime.__dict__.get(name) + return override if callable(override) else None + + def get_github_token(self): + override = self._instance_override("get_github_token") + if override is not None: + return override() + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_github_token(self._runtime_required()) + + def get_github_graphql_token(self, *, prefer_board_token=False): + override = self._instance_override("get_github_graphql_token") + if override is not None: + return override(prefer_board_token=prefer_board_token) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_github_graphql_token(self._runtime_required(), prefer_board_token=prefer_board_token) + + def github_graphql(self, query, variables=None, *, token=None): + override = self._instance_override("github_graphql") + if override is not None: + return override(query, variables, token=token) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.github_graphql(self._runtime_required(), query, variables, token=token) + + def post_comment(self, issue_number: int, body: str): + override = self._instance_override("post_comment") + if override is not None: + return override(issue_number, body) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.post_comment(self._runtime_required(), issue_number, body) + + def get_repo_labels(self): + override = self._instance_override("get_repo_labels") + if override is not None: + return override() + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_repo_labels(self._runtime_required()) + + def add_label(self, issue_number: int, label: str): + override = self._instance_override("add_label") + if override is not None: + return override(issue_number, label) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.add_label(self._runtime_required(), issue_number, label) + + def remove_label(self, issue_number: int, label: str): + override = self._instance_override("remove_label") + if override is not None: + return override(issue_number, label) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.remove_label(self._runtime_required(), issue_number, label) + + def ensure_label_exists(self, label: str, *, color=None, description=None): + override = self._instance_override("ensure_label_exists") + if override is not None: + return override(label, color=color, description=description) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.ensure_label_exists(self._runtime_required(), label, color=color, description=description) + + def get_issue_assignees(self, issue_number: int): + override = self._instance_override("get_issue_assignees") + if override is not None: + return override(issue_number) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_issue_assignees(self._runtime_required(), issue_number) + + def request_reviewer_assignment(self, issue_number: int, username: str): + override = self._instance_override("request_reviewer_assignment") + if override is not None: + return override(issue_number, username) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.request_reviewer_assignment(self._runtime_required(), issue_number, username) + + def get_assignment_failure_comment(self, reviewer: str, attempt): + override = self._instance_override("get_assignment_failure_comment") + if override is not None: + return override(reviewer, attempt) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_assignment_failure_comment(self._runtime_required(), reviewer, attempt) + + def add_reaction(self, comment_id: int, reaction: str): + override = self._instance_override("add_reaction") + if override is not None: + return override(comment_id, reaction) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.add_reaction(self._runtime_required(), comment_id, reaction) + + def remove_assignee(self, issue_number: int, username: str): + override = self._instance_override("remove_assignee") + if override is not None: + return override(issue_number, username) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.remove_assignee(self._runtime_required(), issue_number, username) + + def remove_pr_reviewer(self, issue_number: int, username: str): + override = self._instance_override("remove_pr_reviewer") + if override is not None: + return override(issue_number, username) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.remove_pr_reviewer(self._runtime_required(), issue_number, username) + + def unassign_reviewer(self, issue_number: int, username: str): + override = self._instance_override("unassign_reviewer") + if override is not None: + return override(issue_number, username) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.unassign_reviewer(self._runtime_required(), issue_number, username) + + def get_user_permission_status(self, username: str, required_permission="triage"): + override = self._instance_override("get_user_permission_status") + if override is not None: + return override(username, required_permission) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.get_user_permission_status(self._runtime_required(), username, required_permission) + + def check_user_permission(self, username: str, required_permission="triage"): + override = self._instance_override("check_user_permission") + if override is not None: + return override(username, required_permission) + from scripts.reviewer_bot_lib import github_api as github_api_module + + return github_api_module.check_user_permission(self._runtime_required(), username, required_permission) + + def get_issue_or_pr_snapshot(self, issue_number: int): + override = self._instance_override("get_issue_or_pr_snapshot") + if override is not None: + return override(issue_number) + return self.github_api("GET", f"issues/{issue_number}") + + def get_pull_request_reviews(self, issue_number: int): + override = self._instance_override("get_pull_request_reviews") + if override is not None: + return override(issue_number) + from scripts.reviewer_bot_lib import reviews as reviews_module + + return reviews_module.get_pull_request_reviews(self._runtime_required(), issue_number) + class RestTransportStub: def __init__(self, github_stub: GitHubStub): diff --git a/tests/unit/reviewer_bot/test_github_api.py b/tests/unit/reviewer_bot/test_github_api.py index dd7f74b50..74e88181c 100644 --- a/tests/unit/reviewer_bot/test_github_api.py +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -237,16 +237,16 @@ def test_get_assignment_failure_comment_uses_runtime_config_for_pr_message(monke def test_unassign_reviewer_uses_runtime_config_to_remove_pr_reviewer(monkeypatch): - calls = [] - bot = _bot( - monkeypatch, - config={"IS_PULL_REQUEST": "true"}, - remove_pr_reviewer=lambda issue_number, username: calls.append(("pr", issue_number, username)) or True, - remove_assignee=lambda issue_number, username: calls.append(("issue", issue_number, username)) or True, - ) + github = RouteGitHubApi() + github.add_api("DELETE", "pulls/42/requested_reviewers", {}) + github.add_api("DELETE", "issues/42/assignees", {}) + bot = _bot(monkeypatch, config={"IS_PULL_REQUEST": "true"}, github=github) assert github_api.unassign_reviewer(bot, 42, "alice") is True - assert calls == [("pr", 42, "alice"), ("issue", 42, "alice")] + assert [call.endpoint for call in github.api_calls] == [ + "pulls/42/requested_reviewers", + "issues/42/assignees", + ] def test_find_open_pr_for_branch_status_reports_unavailable_for_malformed_payload(monkeypatch): diff --git a/tests/unit/reviewer_bot/test_reviews_projection.py b/tests/unit/reviewer_bot/test_reviews_projection.py index ab00a2f64..e36bee151 100644 --- a/tests/unit/reviewer_bot/test_reviews_projection.py +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -12,12 +12,15 @@ def _bot(**overrides): - bot = SimpleNamespace( - github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult(200, {}, {}, "ok", True, None, 0, None), - github_api=lambda method, endpoint, data=None: {}, + github = SimpleNamespace( get_pull_request_reviews=lambda issue_number: [], get_issue_or_pr_snapshot=lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []}, get_user_permission_status=lambda username, required_permission="push": "granted", + ) + bot = SimpleNamespace( + github_api_request=lambda method, endpoint, data=None, extra_headers=None, **kwargs: GitHubApiResult(200, {}, {}, "ok", True, None, 0, None), + github_api=lambda method, endpoint, data=None: {}, + github=github, parse_github_timestamp=reviews.parse_github_timestamp, parse_iso8601_timestamp=reviews.parse_github_timestamp, ensure_review_entry=lambda state, issue_number, create=False: None, From a58d9d8bb17b4ab449600049a42424776608a861 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 12:16:59 +0900 Subject: [PATCH 197/209] refactor(reviewer-bot): align github services with slimmer runtime facade --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 57 +++++++++++++++++++ scripts/reviewer_bot_lib/github_api.py | 6 +- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 37277bf92..bdd7be03c 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -54,6 +54,63 @@ def github_api_request(self, *args, **kwargs): def github_api(self, *args, **kwargs): return github_api.github_api(self._runtime_getter(), *args, **kwargs) + def get_github_token(self): + return github_api.get_github_token(self._runtime_getter()) + + def get_github_graphql_token(self, *, prefer_board_token=False): + return github_api.get_github_graphql_token(self._runtime_getter(), prefer_board_token=prefer_board_token) + + def github_graphql(self, query, variables=None, *, token=None): + return github_api.github_graphql(self._runtime_getter(), query, variables, token=token) + + def post_comment(self, issue_number, body): + return github_api.post_comment(self._runtime_getter(), issue_number, body) + + def get_repo_labels(self): + return github_api.get_repo_labels(self._runtime_getter()) + + def add_label(self, issue_number, label): + return github_api.add_label(self._runtime_getter(), issue_number, label) + + def remove_label(self, issue_number, label): + return github_api.remove_label(self._runtime_getter(), issue_number, label) + + def ensure_label_exists(self, label, *, color=None, description=None): + return github_api.ensure_label_exists(self._runtime_getter(), label, color=color, description=description) + + def get_issue_assignees(self, issue_number): + return github_api.get_issue_assignees(self._runtime_getter(), issue_number) + + def request_reviewer_assignment(self, issue_number, username): + return github_api.request_reviewer_assignment(self._runtime_getter(), issue_number, username) + + def get_assignment_failure_comment(self, reviewer, attempt): + return github_api.get_assignment_failure_comment(self._runtime_getter(), reviewer, attempt) + + def add_reaction(self, comment_id, reaction): + return github_api.add_reaction(self._runtime_getter(), comment_id, reaction) + + def remove_assignee(self, issue_number, username): + return github_api.remove_assignee(self._runtime_getter(), issue_number, username) + + def remove_pr_reviewer(self, issue_number, username): + return github_api.remove_pr_reviewer(self._runtime_getter(), issue_number, username) + + def unassign_reviewer(self, issue_number, username): + return github_api.unassign_reviewer(self._runtime_getter(), issue_number, username) + + def get_user_permission_status(self, username, required_permission="triage"): + return github_api.get_user_permission_status(self._runtime_getter(), username, required_permission) + + def check_user_permission(self, username, required_permission="triage"): + return github_api.check_user_permission(self._runtime_getter(), username, required_permission) + + def get_issue_or_pr_snapshot(self, issue_number): + return github_api.github_api(self._runtime_getter(), "GET", f"issues/{issue_number}") + + def get_pull_request_reviews(self, issue_number): + return reviews.get_pull_request_reviews(self._runtime_getter(), issue_number) + class _BootstrapLockServices: def __init__(self, runtime_getter): diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index a0ea07a0a..5bbbddfa2 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -528,7 +528,7 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, def assign_reviewer(bot: GitHubTransportContext, issue_number: int, username: str) -> bool: - return bot.request_reviewer_assignment(issue_number, username).success + return request_reviewer_assignment(bot, issue_number, username).success def get_assignment_failure_comment(bot: GitHubTransportContext, reviewer: str, attempt) -> str | None: @@ -603,8 +603,8 @@ def remove_pr_reviewer(bot: GitHubTransportContext, issue_number: int, username: def unassign_reviewer(bot: GitHubTransportContext, issue_number: int, username: str) -> bool: is_pr = _is_pull_request(bot) if is_pr: - bot.remove_pr_reviewer(issue_number, username) - return bot.remove_assignee(issue_number, username) + remove_pr_reviewer(bot, issue_number, username) + return remove_assignee(bot, issue_number, username) def get_user_permission_status( From e0eb7276312d8a9222dfa41cb5ce87be766f61d5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 23:25:18 +0900 Subject: [PATCH 198/209] refactor(reviewer-bot): remove broad runtime review and queue facade --- scripts/reviewer_bot_lib/automation.py | 28 +++++------ scripts/reviewer_bot_lib/commands.py | 12 ++--- scripts/reviewer_bot_lib/comment_routing.py | 4 +- scripts/reviewer_bot_lib/context.py | 9 +--- scripts/reviewer_bot_lib/lifecycle.py | 4 +- scripts/reviewer_bot_lib/overdue.py | 2 +- scripts/reviewer_bot_lib/project_board.py | 2 +- scripts/reviewer_bot_lib/queue.py | 3 +- scripts/reviewer_bot_lib/reconcile.py | 6 +-- scripts/reviewer_bot_lib/runtime.py | 50 ------------------- tests/fixtures/fake_runtime.py | 45 ++++++++++++++++- tests/unit/reviewer_bot/test_commands.py | 8 +-- .../unit/reviewer_bot/test_comment_routing.py | 8 ++- tests/unit/reviewer_bot/test_project_board.py | 3 +- 14 files changed, 88 insertions(+), 96 deletions(-) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 0f21873a2..6314ae1cd 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -99,7 +99,7 @@ def find_open_pr_for_branch(bot, branch: str) -> dict | None: def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None: - lookup_status, existing = bot.find_open_pr_for_branch_status(branch) + lookup_status, existing = find_open_pr_for_branch_status(bot, branch) if lookup_status == "found": return existing if lookup_status == "unavailable": @@ -143,10 +143,10 @@ def handle_accept_no_fls_changes_command( return "❌ You must have triage permissions to run this command.", False repo_root = Path(privileged_request.target_repo_root) if privileged_request.target_repo_root else get_target_repo_root(bot) - if bot.list_changed_files(repo_root): + if bot.adapters.automation.list_changed_files(repo_root): return "❌ Working tree is not clean; refusing to update spec.lock.", False - audit_result = bot.run_command( + audit_result = bot.adapters.automation.run_command( ["uv", "run", "--locked", "python", "scripts/fls_audit.py", "--summary-only", "--fail-on-impact"], cwd=repo_root, check=False, @@ -158,21 +158,21 @@ def handle_accept_no_fls_changes_command( False, ) if audit_result.returncode != 0: - details = bot.summarize_output(audit_result) + details = bot.adapters.automation.summarize_output(audit_result) detail_text = f"\n\nDetails:\n```\n{details}\n```" if details else "" return f"❌ Audit command failed.{detail_text}", False - update_result = bot.run_command( + update_result = bot.adapters.automation.run_command( ["uv", "run", "--locked", "python", "./make.py", "--update-spec-lock-file"], cwd=repo_root, check=False, ) if update_result.returncode != 0: - details = bot.summarize_output(update_result) + details = bot.adapters.automation.summarize_output(update_result) detail_text = f"\n\nDetails:\n```\n{details}\n```" if details else "" return f"❌ Failed to update spec.lock.{detail_text}", False - changed_files = bot.list_changed_files(repo_root) + changed_files = bot.adapters.automation.list_changed_files(repo_root) if not changed_files: return "✅ `src/spec.lock` is already up to date; no PR needed.", True @@ -186,16 +186,16 @@ def handle_accept_no_fls_changes_command( ) branch_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") - base_branch = bot.get_default_branch() + base_branch = bot.adapters.automation.get_default_branch() branch_name = f"chore/spec-lock-{branch_date}-issue-{issue_number}" - if bot.run_command(["git", "rev-parse", "--verify", branch_name], cwd=repo_root, check=False).returncode == 0: + if bot.adapters.automation.run_command(["git", "rev-parse", "--verify", branch_name], cwd=repo_root, check=False).returncode == 0: suffix = datetime.now(timezone.utc).strftime("%H%M%S") branch_name = f"{branch_name}-{suffix}" try: - bot.run_command(["git", "checkout", "-b", branch_name], cwd=repo_root) - bot.run_command(["git", "add", "src/spec.lock"], cwd=repo_root) - bot.run_command( + bot.adapters.automation.run_command(["git", "checkout", "-b", branch_name], cwd=repo_root) + bot.adapters.automation.run_command(["git", "add", "src/spec.lock"], cwd=repo_root) + bot.adapters.automation.run_command( [ "git", "-c", @@ -208,11 +208,11 @@ def handle_accept_no_fls_changes_command( ], cwd=repo_root, ) - bot.run_command(["git", "push", "origin", branch_name], cwd=repo_root) + bot.adapters.automation.run_command(["git", "push", "origin", branch_name], cwd=repo_root) except RuntimeError as exc: return f"❌ Failed to create branch or push changes: {exc}", False - pr = bot.create_pull_request(branch_name, base_branch, issue_number) + pr = bot.adapters.automation.create_pull_request(branch_name, base_branch, issue_number) if not pr or "html_url" not in pr: return "❌ Failed to open a pull request for the spec.lock update.", False diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index f962e60fc..9f3a620e1 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -134,7 +134,7 @@ def _apply_assignment_side_effects( issue_number = request.issue_number assignment_attempt = bot.github.request_reviewer_assignment(issue_number, reviewer) review_state.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) - bot.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") + bot.adapters.review.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: bot.github.post_comment(issue_number, failure_comment) @@ -187,10 +187,10 @@ def handle_pass_command( skip_set = set(issue_data["skipped"]) if assignment_request.issue_author: skip_set.add(assignment_request.issue_author) - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return ("❌ No other reviewers available. Everyone in the queue has either passed on this issue or is the author."), False - bot.reposition_member_as_next(state, passed_reviewer) + bot.adapters.review.reposition_member_as_next(state, passed_reviewer) bot.github.unassign_reviewer(issue_number, passed_reviewer) assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, @@ -269,7 +269,7 @@ def handle_pass_until_command( if is_current_reviewer: bot.github.unassign_reviewer(issue_number, comment_author) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) if next_reviewer: assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, @@ -526,7 +526,7 @@ def handle_release_command( if "active_reviews" in state and issue_key in state["active_reviews"] and isinstance(state["active_reviews"][issue_key], dict): state["active_reviews"][issue_key]["current_reviewer"] = None if assignment_method == "round-robin": - bot.reposition_member_as_next(state, target_username) + bot.adapters.review.reposition_member_as_next(state, target_username) reason_text = f" Reason: {reason}" if reason else "" if releasing_other: return (f"✅ @{comment_author} has released @{target_username} from this review.{reason_text}\n\n_This issue/PR is now unassigned. Use `{bot.BOT_MENTION} /r? producers` to assign the next reviewer from the queue, or `{bot.BOT_MENTION} /claim` to claim it._"), True @@ -587,7 +587,7 @@ def handle_assign_from_queue_command( for assignee in current_assignees: bot.github.unassign_reviewer(issue_number, assignee) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return (f"❌ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue."), False assignment_attempt, _failure_comment = _apply_assignment_side_effects( diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index e404615c2..ab61c22f5 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -89,7 +89,7 @@ def _comment_line_is_command(bot, line: str) -> bool: def classify_comment_payload(bot, body: str) -> dict: - normalized = _normalize_comment_body(bot.strip_code_blocks(body)) + normalized = _normalize_comment_body(bot.adapters.review.strip_code_blocks(body)) if not normalized: return { "comment_class": "empty_or_whitespace", @@ -102,7 +102,7 @@ def classify_comment_payload(bot, body: str) -> dict: lines = [line for line in normalized.splitlines() if line.strip()] command_lines = [line for line in lines if _comment_line_is_command(bot, line)] non_command_lines = [line for line in lines if not _comment_line_is_command(bot, line)] - parsed = bot.parse_command(normalized) + parsed = bot.adapters.review.parse_command(normalized) command = None args: list[str] = [] if parsed: diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 8b1d32740..1485dd222 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -234,14 +234,7 @@ def github_graphql( @runtime_checkable class ProjectBoardProjectionContext(Protocol): github: Any - - def compute_reviewer_response_state( - self, - issue_number: int, - review_data: dict, - *, - issue_snapshot: dict | None = None, - ) -> dict: ... + adapters: Any @runtime_checkable diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 36ae20dcb..430204c3a 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -99,7 +99,7 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: if not any(label in bot.REVIEW_LABELS for label in labels): return False issue_author = request.issue_author - reviewer = bot.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) + reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) if not reviewer: bot.github.post_comment(issue_number, f"⚠️ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue.") return False @@ -111,7 +111,7 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: head_sha = request.pr_head_sha if head_sha: review_data["active_head_sha"] = head_sha - bot.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") + bot.adapters.review.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: bot.github.post_comment(issue_number, failure_comment) diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index b374789d6..c14845b86 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -35,7 +35,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: _log(bot, "warning", f"Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable", issue_number=issue_number) continue if isinstance(issue_snapshot.get("pull_request"), dict): - response_state = bot.compute_reviewer_response_state( + response_state = bot.adapters.review.compute_reviewer_response_state( issue_number, review_data, issue_snapshot=issue_snapshot, diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index 3a95e3d5f..986c287c8 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -265,7 +265,7 @@ def _derive_review_state( issue_snapshot: dict[str, Any], ) -> ReviewStateDerivation: preview_review_data = copy.deepcopy(review_data_snapshot) - derived = bot.compute_reviewer_response_state( + derived = bot.adapters.review.compute_reviewer_response_state( issue_number, preview_review_data, issue_snapshot=copy.deepcopy(issue_snapshot), diff --git a/scripts/reviewer_bot_lib/queue.py b/scripts/reviewer_bot_lib/queue.py index 5cba44a37..28f103ab9 100644 --- a/scripts/reviewer_bot_lib/queue.py +++ b/scripts/reviewer_bot_lib/queue.py @@ -6,7 +6,7 @@ def sync_members_with_queue(bot, state: dict) -> tuple[dict, list[str]]: """Sync the queue with the current members list.""" - producers = bot.fetch_members() + producers = bot.adapters.automation.fetch_members() current_queue = {member["github"]: member for member in state["queue"]} pass_until_users = {member["github"] for member in state.get("pass_until", [])} @@ -143,4 +143,3 @@ def record_assignment( state["recent_assignments"].insert(0, assignment) state["recent_assignments"] = state["recent_assignments"][:max_recent_assignments] - diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 77deb65f8..ae64e8465 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -335,7 +335,7 @@ def reconcile_active_review_entry( return f"ℹ️ #{issue_number} is not a pull request in this event context; `/rectify` only reconciles PR reviews.", True, False if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15" and bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true": return "ℹ️ PR review freshness rectify is epoch-gated and currently inactive.", True, False - head_repair_result = bot.maybe_record_head_observation_repair(issue_number, review_data) + head_repair_result = bot.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) state_changed = head_repair_result.changed try: reviews = _read_reconcile_reviews(bot, issue_number) @@ -968,7 +968,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: else: live_commit_id = parsed_payload.source_commit_id actor = context.actor_login - state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed + state_changed = bot.adapters.review.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): accept_channel_event( review_data, @@ -1000,7 +1000,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: timestamp=_now_iso(bot), dismissal_only=True, ) - state_changed = bot.maybe_record_head_observation_repair(pr_number, review_data).changed + state_changed = bot.adapters.review.maybe_record_head_observation_repair(pr_number, review_data).changed if _record_review_rebuild(bot, state, pr_number, review_data): state_changed = True _mark_reconciled_source_event(review_data, source_event_key) diff --git a/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py index 46dde70cf..64dc4a1a7 100644 --- a/scripts/reviewer_bot_lib/runtime.py +++ b/scripts/reviewer_bot_lib/runtime.py @@ -384,51 +384,6 @@ def github_graphql(self, query: str, variables=None, *, token=None): return self.adapters.github.github_graphql(query, variables, token=token) # Adapter-only mutable review-state compatibility surface. - def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): - return self.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) - - def get_next_reviewer(self, state: dict, skip_usernames=None): - return self.adapters.review.get_next_reviewer(state, skip_usernames) - - def strip_code_blocks(self, comment_body: str) -> str: - return self.adapters.review.strip_code_blocks(comment_body) - - def parse_command(self, comment_body: str): - return self.adapters.review.parse_command(comment_body) - - def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: - return self.adapters.review.record_assignment(state, github, issue_number, kind) - - def reposition_member_as_next(self, state: dict, username: str) -> bool: - return self.adapters.review.reposition_member_as_next(state, username) - - def parse_iso8601_timestamp(self, value: Any): - return self.adapters.state_lock.parse_iso8601_timestamp(value) - - def compute_reviewer_response_state(self, issue_number: int, review_data: dict, *, issue_snapshot=None): - return self.adapters.review.compute_reviewer_response_state(issue_number, review_data, issue_snapshot=issue_snapshot) - - def run_command(self, command, cwd, check=False): - return self.adapters.automation.run_command(command, cwd, check) - - def summarize_output(self, result, limit: int = 20) -> str: - return self.adapters.automation.summarize_output(result, limit) - - def list_changed_files(self, repo_root): - return self.adapters.automation.list_changed_files(repo_root) - - def get_default_branch(self) -> str: - return self.adapters.automation.get_default_branch() - - def find_open_pr_for_branch_status(self, branch: str): - return self.adapters.automation.find_open_pr_for_branch_status(branch) - - def create_pull_request(self, branch: str, base: str, issue_number: int): - return self.adapters.automation.create_pull_request(branch, base, issue_number) - - def parse_issue_labels(self) -> list[str]: - return self.adapters.automation.parse_issue_labels() - def normalize_lock_metadata(self, lock_meta: dict | None): return self.adapters.state_lock.normalize_lock_metadata(lock_meta) @@ -493,8 +448,3 @@ def release_state_issue_lease_lock(self) -> bool: result = self.adapters.state_lock.release_state_issue_lease_lock() self.ACTIVE_LEASE_CONTEXT = self.adapters.state_lock.get_active_lease_context() return result - - def fetch_members(self): - from . import members as members_module - - return members_module.fetch_members(self) diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index f6420213b..7bf193246 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -82,6 +82,8 @@ class FakeRuntimeAdapterServices: def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime self.workflow = runtime.workflow + self.review = runtime.compat.review + self.automation = runtime.compat.automation def process_pass_until_expirations(self, state: dict): return self._runtime.workflow.process_pass_until_expirations(state) @@ -93,6 +95,11 @@ def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._runtime.workflow.sync_status_labels_for_items(state, issue_numbers) +def _instance_override(runtime: "FakeReviewerBotRuntime", name: str): + override = runtime.__dict__.get(name) + return override if callable(override) else None + + class FakeRuntimeGitHubCompatibility: def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime @@ -162,6 +169,9 @@ def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): + override = _instance_override(self._runtime, "maybe_record_head_observation_repair") + if override is not None: + return override(issue_number, review_data) return lifecycle_module.maybe_record_head_observation_repair(self._runtime, issue_number, review_data) def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: @@ -234,6 +244,9 @@ def reposition_member_as_next(self, state: dict, username: str) -> bool: return queue_module.reposition_member_as_next(state, username) def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): + override = _instance_override(self._runtime, "compute_reviewer_response_state") + if override is not None: + return override(issue_number, state, issue_snapshot=issue_snapshot) return reviews_module.compute_reviewer_response_state(self._runtime, issue_number, state, issue_snapshot=issue_snapshot) @@ -304,27 +317,51 @@ def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime def run_command(self, command, cwd, check=False): + override = _instance_override(self._runtime, "run_command") + if override is not None: + return override(command, cwd, check=check) return automation_module.run_command(command, cwd=cwd, check=check) def summarize_output(self, result, limit: int = 20) -> str: + override = _instance_override(self._runtime, "summarize_output") + if override is not None: + return override(result, limit) return automation_module.summarize_output(result, limit=limit) def list_changed_files(self, repo_root): + override = _instance_override(self._runtime, "list_changed_files") + if override is not None: + return override(repo_root) return automation_module.list_changed_files(repo_root) def get_default_branch(self) -> str: + override = _instance_override(self._runtime, "get_default_branch") + if override is not None: + return override() return automation_module.get_default_branch(self._runtime) def find_open_pr_for_branch_status(self, branch: str): + override = _instance_override(self._runtime, "find_open_pr_for_branch_status") + if override is not None: + return override(branch) return automation_module.find_open_pr_for_branch_status(self._runtime, branch) def create_pull_request(self, branch: str, base: str, issue_number: int): + override = _instance_override(self._runtime, "create_pull_request") + if override is not None: + return override(branch, base, issue_number) return automation_module.create_pull_request(self._runtime, branch, base, issue_number) def parse_issue_labels(self) -> list[str]: + override = _instance_override(self._runtime, "parse_issue_labels") + if override is not None: + return override() return automation_module.bot_parse_issue_labels(self._runtime) def fetch_members(self): + override = _instance_override(self._runtime, "fetch_members") + if override is not None: + return override() return self._runtime._fetch_members() def handle_accept_no_fls_changes_command(self, issue_number: int, comment_author: str, request=None): @@ -376,13 +413,13 @@ def __init__(self, monkeypatch, *, github=None): self.locks = LockStub() self.workflow = WorkflowBehaviorStub() self._fetch_members = lambda: [] - self.adapters = FakeRuntimeAdapterServices(self) self.compat = SimpleNamespace( github=FakeRuntimeGitHubCompatibility(self), review=FakeRuntimeReviewCompatibility(self), state_lock=FakeRuntimeStateLockCompatibility(self), automation=FakeRuntimeAutomationCompatibility(self), ) + self.adapters = FakeRuntimeAdapterServices(self) self.handlers = HandlerStub( { "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), @@ -716,9 +753,15 @@ def handle_workflow_run_event(self, state: dict) -> bool: return self.handlers.call("handle_workflow_run_event", state) def github_api(self, method: str, endpoint: str, data=None): + override = _instance_override(self, "github_api") + if override is not None: + return override(method, endpoint, data=data) return self.github.github_api(method, endpoint, data=data) def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): + override = _instance_override(self, "github_api_request") + if override is not None: + return override(method, endpoint, data=data, extra_headers=extra_headers, **kwargs) return self.github.github_api_request( method, endpoint, diff --git a/tests/unit/reviewer_bot/test_commands.py b/tests/unit/reviewer_bot/test_commands.py index ea5b8a301..280ac0cd5 100644 --- a/tests/unit/reviewer_bot/test_commands.py +++ b/tests/unit/reviewer_bot/test_commands.py @@ -83,7 +83,10 @@ def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch) def test_create_pull_request_fails_closed_when_open_pr_lookup_unavailable(monkeypatch): harness = CommandHarness(monkeypatch) called = {"post": 0} - harness.runtime.find_open_pr_for_branch_status = lambda branch: ("unavailable", None) + harness.runtime.set_config_value("REPO_OWNER", "rustfoundation") + harness.runtime.github_api_request = lambda method, endpoint, data=None, extra_headers=None, **kwargs: harness.runtime.GitHubApiResult( + 502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None + ) harness.runtime.github_api = lambda method, endpoint, data=None: called.__setitem__("post", called["post"] + 1) or None with pytest.raises(RuntimeError, match="Unable to determine whether branch 'feature-branch' already has an open PR"): @@ -384,7 +387,6 @@ def test_parse_command_preserves_quoted_args(monkeypatch): def test_strip_code_blocks_removes_fenced_indented_and_inline_code(monkeypatch): - harness = CommandHarness(monkeypatch) comment_body = """before ```bash @guidelines-bot /queue @@ -393,4 +395,4 @@ def test_strip_code_blocks_removes_fenced_indented_and_inline_code(monkeypatch): inline `@guidelines-bot /queue` after""" - assert harness.runtime.strip_code_blocks(comment_body) == "before\n\n\ninline \nafter" + assert commands.strip_code_blocks(comment_body) == "before\n\n\ninline \nafter" diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index e7f967a35..6b414affc 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -79,8 +79,12 @@ def test_classify_comment_payload_distinguishes_command_plus_text(): payload = comment_routing.classify_comment_payload( SimpleNamespace( BOT_MENTION="@guidelines-bot", - strip_code_blocks=lambda body: body, - parse_command=lambda body: ("queue", []), + adapters=SimpleNamespace( + review=SimpleNamespace( + strip_code_blocks=lambda body: body, + parse_command=lambda body: ("queue", []), + ) + ), ), "hello\n@guidelines-bot /queue", ) diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 6579b30e6..0dab3ef67 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -110,9 +110,10 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): accept_reviewer_comment(review, semantic_key="issue_comment:1", timestamp="2026-03-21T08:00:00Z", actor="alice") accept_reviewer_review(review, semantic_key="pull_request_review:10", timestamp="2026-03-21T08:00:00Z", actor="alice", reviewed_head_sha="head-1", source_precedence=1) routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + routes.add_pull_request_reviews(42, []) runtime = _runtime(monkeypatch, routes) runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) - runtime.compute_reviewer_response_state = lambda issue_number, review_data, **kwargs: { + runtime.adapters.review.compute_reviewer_response_state = lambda issue_number, review_data, **kwargs: { "state": "awaiting_reviewer_response", "anchor_timestamp": "2026-03-21T08:00:00Z", } From bcc7e2365c97071fe9c96fb0f9264e26a2793d95 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sun, 5 Apr 2026 23:35:09 +0900 Subject: [PATCH 199/209] refactor(reviewer-bot): thin bootstrap compatibility adapters --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 81 ++----------------- .../reviewer_bot/test_adapter_contract.py | 1 + 2 files changed, 8 insertions(+), 74 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index bdd7be03c..7f9c0599b 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -161,74 +161,6 @@ def handle_workflow_run_event(self, current_state): return reconcile.handle_workflow_run_event(self._runtime_getter(), current_state) -class _BootstrapGitHubAdapterServices: - def __init__(self, runtime_getter): - self._runtime_getter = runtime_getter - - def _runtime(self): - return self._runtime_getter() - - def assert_lock_held(self, context): - return state_store.assert_lock_held(self._runtime(), context) - - def get_github_token(self): - return github_api.get_github_token(self._runtime()) - - def get_github_graphql_token(self, *, prefer_board_token=False): - return github_api.get_github_graphql_token(self._runtime(), prefer_board_token=prefer_board_token) - - def github_graphql(self, query, variables=None, *, token=None): - return github_api.github_graphql(self._runtime(), query, variables, token=token) - - def post_comment(self, issue_number, body): - return github_api.post_comment(self._runtime(), issue_number, body) - - def get_repo_labels(self): - return github_api.get_repo_labels(self._runtime()) - - def add_label(self, issue_number, label): - return github_api.add_label(self._runtime(), issue_number, label) - - def remove_label(self, issue_number, label): - return github_api.remove_label(self._runtime(), issue_number, label) - - def ensure_label_exists(self, label, *, color=None, description=None): - return github_api.ensure_label_exists(self._runtime(), label, color=color, description=description) - - def get_issue_assignees(self, issue_number): - return github_api.get_issue_assignees(self._runtime(), issue_number) - - def request_reviewer_assignment(self, issue_number, username): - return github_api.request_reviewer_assignment(self._runtime(), issue_number, username) - - def get_assignment_failure_comment(self, reviewer, attempt): - return github_api.get_assignment_failure_comment(self._runtime(), reviewer, attempt) - - def add_reaction(self, comment_id, reaction): - return github_api.add_reaction(self._runtime(), comment_id, reaction) - - def remove_assignee(self, issue_number, username): - return github_api.remove_assignee(self._runtime(), issue_number, username) - - def remove_pr_reviewer(self, issue_number, username): - return github_api.remove_pr_reviewer(self._runtime(), issue_number, username) - - def unassign_reviewer(self, issue_number, username): - return github_api.unassign_reviewer(self._runtime(), issue_number, username) - - def get_user_permission_status(self, username, required_permission="triage"): - return github_api.get_user_permission_status(self._runtime(), username, required_permission) - - def check_user_permission(self, username, required_permission="triage"): - return github_api.check_user_permission(self._runtime(), username, required_permission) - - def get_issue_or_pr_snapshot(self, issue_number): - return github_api.github_api(self._runtime(), "GET", f"issues/{issue_number}") - - def get_pull_request_reviews(self, issue_number): - return reviews.get_pull_request_reviews(self._runtime(), issue_number) - - class _BootstrapReviewAdapterServices: def __init__(self, runtime_getter): self._runtime_getter = runtime_getter @@ -372,8 +304,9 @@ def parse_issue_labels(self): class _BootstrapStateLockAdapterServices: - def __init__(self, runtime_getter): + def __init__(self, runtime_getter, lock_services): self._runtime_getter = runtime_getter + self._lock_services = lock_services def _runtime(self): return self._runtime_getter() @@ -427,13 +360,13 @@ def renew_state_issue_lease_lock(self, context): return lease_lock.renew_state_issue_lease_lock(self._runtime(), context) def ensure_state_issue_lease_lock_fresh(self): - return lease_lock.ensure_state_issue_lease_lock_fresh(self._runtime()) + return self._lock_services.refresh() def acquire_state_issue_lease_lock(self): - return lease_lock.acquire_state_issue_lease_lock(self._runtime()) + return self._lock_services.acquire() def release_state_issue_lease_lock(self): - return lease_lock.release_state_issue_lease_lock(self._runtime()) + return self._lock_services.release() def get_active_lease_context(self): return self._runtime().ACTIVE_LEASE_CONTEXT @@ -464,11 +397,11 @@ def runtime_getter() -> ReviewerBotRuntime: lock_services = _BootstrapLockServices(runtime_getter) handlers = _BootstrapHandlerServices(runtime_getter) adapters = _BootstrapAdapterGroups( - github=_BootstrapGitHubAdapterServices(runtime_getter), + github=github_services, review=_BootstrapReviewAdapterServices(runtime_getter), workflow=_BootstrapWorkflowAdapterServices(runtime_getter), automation=_BootstrapAutomationAdapterServices(runtime_getter), - state_lock=_BootstrapStateLockAdapterServices(runtime_getter), + state_lock=_BootstrapStateLockAdapterServices(runtime_getter, lock_services), ) runtime = ReviewerBotRuntime( diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 2abc93718..7027166ab 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -366,6 +366,7 @@ def test_bootstrap_runtime_wires_explicit_adapter_services(): assert hasattr(runtime.adapters, "workflow") assert hasattr(runtime.adapters, "automation") assert hasattr(runtime.adapters, "state_lock") + assert runtime.adapters.github is runtime.github assert hasattr(runtime.adapters.github, "get_github_token") assert hasattr(runtime.adapters.review, "handle_pass_command") assert hasattr(runtime.adapters.state_lock, "render_state_issue_body") From 553661824949c71e1dbe17689ad1f212495d6f17 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 00:02:16 +0900 Subject: [PATCH 200/209] refactor(reviewer-bot): split bootstrap adapter wiring by responsibility --- scripts/reviewer_bot_lib/bootstrap_runtime.py | 78 +++++++++++-------- .../reviewer_bot/test_adapter_contract.py | 10 ++- tests/fixtures/fake_runtime.py | 29 +++---- .../unit/reviewer_bot/test_comment_routing.py | 2 +- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/scripts/reviewer_bot_lib/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py index 7f9c0599b..62694fb14 100644 --- a/scripts/reviewer_bot_lib/bootstrap_runtime.py +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -161,7 +161,7 @@ def handle_workflow_run_event(self, current_state): return reconcile.handle_workflow_run_event(self._runtime_getter(), current_state) -class _BootstrapReviewAdapterServices: +class _BootstrapReviewStateAdapterServices: def __init__(self, runtime_getter): self._runtime_getter = runtime_getter @@ -174,6 +174,39 @@ def maybe_record_head_observation_repair(self, issue_number, review_data): def handle_transition_notice(self, current_state, issue_number, reviewer): return lifecycle.handle_transition_notice(self._runtime(), current_state, issue_number, reviewer) + # Adapter-only mutable review-state compatibility surface. + def ensure_review_entry(self, current_state, issue_number, create=False): + return review_state.ensure_review_entry(current_state, issue_number, create=create) + + def set_current_reviewer(self, current_state, issue_number, reviewer, assignment_method="round-robin"): + return review_state.set_current_reviewer(current_state, issue_number, reviewer, assignment_method=assignment_method) + + def update_reviewer_activity(self, current_state, issue_number, reviewer): + return review_state.update_reviewer_activity(current_state, issue_number, reviewer) + + def mark_review_complete(self, current_state, issue_number, reviewer, source): + return review_state.mark_review_complete(current_state, issue_number, reviewer, source) + + def is_triage_or_higher(self, username): + return reviews.is_triage_or_higher(self._runtime(), username) + + def trigger_mandatory_approver_escalation(self, current_state, issue_number): + return reviews.trigger_mandatory_approver_escalation(self._runtime(), current_state, issue_number) + + def satisfy_mandatory_approver_requirement(self, current_state, issue_number, approver): + return reviews.satisfy_mandatory_approver_requirement(self._runtime(), current_state, issue_number, approver) + + def compute_reviewer_response_state(self, issue_number, review_data, *, issue_snapshot=None): + return reviews.compute_reviewer_response_state(self._runtime(), issue_number, review_data, issue_snapshot=issue_snapshot) + + +class _BootstrapCommandAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + def handle_pass_command(self, current_state, issue_number, comment_author, reason, request=None): return commands.handle_pass_command(self._runtime(), current_state, issue_number, comment_author, reason, request=request) @@ -213,46 +246,23 @@ def handle_accept_no_fls_changes_command(self, issue_number, comment_author, req def get_commands_help(self): return config.get_commands_help() - # Adapter-only mutable review-state compatibility surface. - def ensure_review_entry(self, current_state, issue_number, create=False): - return review_state.ensure_review_entry(current_state, issue_number, create=create) - - def set_current_reviewer(self, current_state, issue_number, reviewer, assignment_method="round-robin"): - return review_state.set_current_reviewer(current_state, issue_number, reviewer, assignment_method=assignment_method) - - def update_reviewer_activity(self, current_state, issue_number, reviewer): - return review_state.update_reviewer_activity(current_state, issue_number, reviewer) - - def mark_review_complete(self, current_state, issue_number, reviewer, source): - return review_state.mark_review_complete(current_state, issue_number, reviewer, source) - - def is_triage_or_higher(self, username): - return reviews.is_triage_or_higher(self._runtime(), username) - - def trigger_mandatory_approver_escalation(self, current_state, issue_number): - return reviews.trigger_mandatory_approver_escalation(self._runtime(), current_state, issue_number) - - def satisfy_mandatory_approver_requirement(self, current_state, issue_number, approver): - return reviews.satisfy_mandatory_approver_requirement(self._runtime(), current_state, issue_number, approver) - - def get_next_reviewer(self, state, skip_usernames=None): - return get_next_reviewer(state, skip_usernames) - def strip_code_blocks(self, comment_body): return commands.strip_code_blocks(comment_body) def parse_command(self, comment_body): return commands.parse_command(self._runtime(), comment_body) + +class _BootstrapQueueAdapterServices: + def get_next_reviewer(self, state, skip_usernames=None): + return get_next_reviewer(state, skip_usernames) + def record_assignment(self, state, github, issue_number, kind): return record_assignment(state, github, issue_number, kind) def reposition_member_as_next(self, state, username): return reposition_member_as_next(state, username) - def compute_reviewer_response_state(self, issue_number, review_data, *, issue_snapshot=None): - return reviews.compute_reviewer_response_state(self._runtime(), issue_number, review_data, issue_snapshot=issue_snapshot) - class _BootstrapWorkflowAdapterServices: def __init__(self, runtime_getter): @@ -373,9 +383,11 @@ def get_active_lease_context(self): class _BootstrapAdapterGroups: - def __init__(self, *, github, review, workflow, automation, state_lock): + def __init__(self, *, github, review_state, commands, queue, workflow, automation, state_lock): self.github = github - self.review = review + self.review_state = review_state + self.commands = commands + self.queue = queue self.workflow = workflow self.automation = automation self.state_lock = state_lock @@ -398,7 +410,9 @@ def runtime_getter() -> ReviewerBotRuntime: handlers = _BootstrapHandlerServices(runtime_getter) adapters = _BootstrapAdapterGroups( github=github_services, - review=_BootstrapReviewAdapterServices(runtime_getter), + review_state=_BootstrapReviewStateAdapterServices(runtime_getter), + commands=_BootstrapCommandAdapterServices(runtime_getter), + queue=_BootstrapQueueAdapterServices(), workflow=_BootstrapWorkflowAdapterServices(runtime_getter), automation=_BootstrapAutomationAdapterServices(runtime_getter), state_lock=_BootstrapStateLockAdapterServices(runtime_getter, lock_services), diff --git a/tests/contract/reviewer_bot/test_adapter_contract.py b/tests/contract/reviewer_bot/test_adapter_contract.py index 7027166ab..0eefe8a19 100644 --- a/tests/contract/reviewer_bot/test_adapter_contract.py +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -69,7 +69,7 @@ def test_runtime_head_repair_contract_is_runtime_scoped(): def test_runtime_review_state_adapter_mutates_active_reviews(): state = make_state() - review = reviewer_bot._runtime_bot().adapters.review.ensure_review_entry(state, 42, create=True) + review = reviewer_bot._runtime_bot().adapters.review_state.ensure_review_entry(state, 42, create=True) assert review is state["active_reviews"]["42"] @@ -362,13 +362,17 @@ def test_bootstrap_runtime_wires_explicit_adapter_services(): runtime = reviewer_bot._runtime_bot() assert hasattr(runtime.adapters, "github") - assert hasattr(runtime.adapters, "review") + assert hasattr(runtime.adapters, "review_state") + assert hasattr(runtime.adapters, "commands") + assert hasattr(runtime.adapters, "queue") assert hasattr(runtime.adapters, "workflow") assert hasattr(runtime.adapters, "automation") assert hasattr(runtime.adapters, "state_lock") assert runtime.adapters.github is runtime.github assert hasattr(runtime.adapters.github, "get_github_token") - assert hasattr(runtime.adapters.review, "handle_pass_command") + assert hasattr(runtime.adapters.review_state, "ensure_review_entry") + assert hasattr(runtime.adapters.commands, "handle_pass_command") + assert hasattr(runtime.adapters.queue, "get_next_reviewer") assert hasattr(runtime.adapters.state_lock, "render_state_issue_body") diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 7bf193246..608b7780f 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -83,6 +83,9 @@ def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime self.workflow = runtime.workflow self.review = runtime.compat.review + self.review_state = runtime.compat.review + self.commands = runtime.compat.review + self.queue = runtime.compat.review self.automation = runtime.compat.automation def process_pass_until_expirations(self, state: dict): @@ -95,7 +98,7 @@ def sync_status_labels_for_items(self, state: dict, issue_numbers): return self._runtime.workflow.sync_status_labels_for_items(state, issue_numbers) -def _instance_override(runtime: "FakeReviewerBotRuntime", name: str): +def runtime_instance_override(runtime: "FakeReviewerBotRuntime", name: str): override = runtime.__dict__.get(name) return override if callable(override) else None @@ -169,7 +172,7 @@ def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): - override = _instance_override(self._runtime, "maybe_record_head_observation_repair") + override = runtime_instance_override(self._runtime, "maybe_record_head_observation_repair") if override is not None: return override(issue_number, review_data) return lifecycle_module.maybe_record_head_observation_repair(self._runtime, issue_number, review_data) @@ -244,7 +247,7 @@ def reposition_member_as_next(self, state: dict, username: str) -> bool: return queue_module.reposition_member_as_next(state, username) def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): - override = _instance_override(self._runtime, "compute_reviewer_response_state") + override = runtime_instance_override(self._runtime, "compute_reviewer_response_state") if override is not None: return override(issue_number, state, issue_snapshot=issue_snapshot) return reviews_module.compute_reviewer_response_state(self._runtime, issue_number, state, issue_snapshot=issue_snapshot) @@ -317,49 +320,49 @@ def __init__(self, runtime: "FakeReviewerBotRuntime"): self._runtime = runtime def run_command(self, command, cwd, check=False): - override = _instance_override(self._runtime, "run_command") + override = runtime_instance_override(self._runtime, "run_command") if override is not None: return override(command, cwd, check=check) return automation_module.run_command(command, cwd=cwd, check=check) def summarize_output(self, result, limit: int = 20) -> str: - override = _instance_override(self._runtime, "summarize_output") + override = runtime_instance_override(self._runtime, "summarize_output") if override is not None: return override(result, limit) return automation_module.summarize_output(result, limit=limit) def list_changed_files(self, repo_root): - override = _instance_override(self._runtime, "list_changed_files") + override = runtime_instance_override(self._runtime, "list_changed_files") if override is not None: return override(repo_root) return automation_module.list_changed_files(repo_root) def get_default_branch(self) -> str: - override = _instance_override(self._runtime, "get_default_branch") + override = runtime_instance_override(self._runtime, "get_default_branch") if override is not None: return override() return automation_module.get_default_branch(self._runtime) def find_open_pr_for_branch_status(self, branch: str): - override = _instance_override(self._runtime, "find_open_pr_for_branch_status") + override = runtime_instance_override(self._runtime, "find_open_pr_for_branch_status") if override is not None: return override(branch) return automation_module.find_open_pr_for_branch_status(self._runtime, branch) def create_pull_request(self, branch: str, base: str, issue_number: int): - override = _instance_override(self._runtime, "create_pull_request") + override = runtime_instance_override(self._runtime, "create_pull_request") if override is not None: return override(branch, base, issue_number) return automation_module.create_pull_request(self._runtime, branch, base, issue_number) def parse_issue_labels(self) -> list[str]: - override = _instance_override(self._runtime, "parse_issue_labels") + override = runtime_instance_override(self._runtime, "parse_issue_labels") if override is not None: return override() return automation_module.bot_parse_issue_labels(self._runtime) def fetch_members(self): - override = _instance_override(self._runtime, "fetch_members") + override = runtime_instance_override(self._runtime, "fetch_members") if override is not None: return override() return self._runtime._fetch_members() @@ -753,13 +756,13 @@ def handle_workflow_run_event(self, state: dict) -> bool: return self.handlers.call("handle_workflow_run_event", state) def github_api(self, method: str, endpoint: str, data=None): - override = _instance_override(self, "github_api") + override = runtime_instance_override(self, "github_api") if override is not None: return override(method, endpoint, data=data) return self.github.github_api(method, endpoint, data=data) def github_api_request(self, method: str, endpoint: str, data=None, extra_headers=None, **kwargs): - override = _instance_override(self, "github_api_request") + override = runtime_instance_override(self, "github_api_request") if override is not None: return override(method, endpoint, data=data, extra_headers=extra_headers, **kwargs) return self.github.github_api_request( diff --git a/tests/unit/reviewer_bot/test_comment_routing.py b/tests/unit/reviewer_bot/test_comment_routing.py index 6b414affc..a67343e50 100644 --- a/tests/unit/reviewer_bot/test_comment_routing.py +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -80,7 +80,7 @@ def test_classify_comment_payload_distinguishes_command_plus_text(): SimpleNamespace( BOT_MENTION="@guidelines-bot", adapters=SimpleNamespace( - review=SimpleNamespace( + commands=SimpleNamespace( strip_code_blocks=lambda body: body, parse_command=lambda body: ("queue", []), ) From abb8e103c7d64727e29cbe0c773a9ff0abbdd33b Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 00:03:03 +0900 Subject: [PATCH 201/209] refactor(reviewer-bot): align callers with split bootstrap adapters --- scripts/reviewer_bot_lib/commands.py | 12 ++++++------ scripts/reviewer_bot_lib/comment_routing.py | 4 ++-- scripts/reviewer_bot_lib/lifecycle.py | 4 ++-- scripts/reviewer_bot_lib/overdue.py | 2 +- scripts/reviewer_bot_lib/project_board.py | 2 +- scripts/reviewer_bot_lib/reconcile.py | 6 +++--- tests/unit/reviewer_bot/test_project_board.py | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 9f3a620e1..bd066e018 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -134,7 +134,7 @@ def _apply_assignment_side_effects( issue_number = request.issue_number assignment_attempt = bot.github.request_reviewer_assignment(issue_number, reviewer) review_state.set_current_reviewer(state, issue_number, reviewer, assignment_method=assignment_method) - bot.adapters.review.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") + bot.adapters.queue.record_assignment(state, reviewer, issue_number, "pr" if request.is_pull_request else "issue") failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: bot.github.post_comment(issue_number, failure_comment) @@ -187,10 +187,10 @@ def handle_pass_command( skip_set = set(issue_data["skipped"]) if assignment_request.issue_author: skip_set.add(assignment_request.issue_author) - next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.queue.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return ("❌ No other reviewers available. Everyone in the queue has either passed on this issue or is the author."), False - bot.adapters.review.reposition_member_as_next(state, passed_reviewer) + bot.adapters.queue.reposition_member_as_next(state, passed_reviewer) bot.github.unassign_reviewer(issue_number, passed_reviewer) assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, @@ -269,7 +269,7 @@ def handle_pass_until_command( if is_current_reviewer: bot.github.unassign_reviewer(issue_number, comment_author) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() - next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.queue.get_next_reviewer(state, skip_usernames=skip_set) if next_reviewer: assignment_attempt, failure_comment = _apply_assignment_side_effects( bot, @@ -526,7 +526,7 @@ def handle_release_command( if "active_reviews" in state and issue_key in state["active_reviews"] and isinstance(state["active_reviews"][issue_key], dict): state["active_reviews"][issue_key]["current_reviewer"] = None if assignment_method == "round-robin": - bot.adapters.review.reposition_member_as_next(state, target_username) + bot.adapters.queue.reposition_member_as_next(state, target_username) reason_text = f" Reason: {reason}" if reason else "" if releasing_other: return (f"✅ @{comment_author} has released @{target_username} from this review.{reason_text}\n\n_This issue/PR is now unassigned. Use `{bot.BOT_MENTION} /r? producers` to assign the next reviewer from the queue, or `{bot.BOT_MENTION} /claim` to claim it._"), True @@ -587,7 +587,7 @@ def handle_assign_from_queue_command( for assignee in current_assignees: bot.github.unassign_reviewer(issue_number, assignee) skip_set = {assignment_request.issue_author} if assignment_request.issue_author else set() - next_reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames=skip_set) + next_reviewer = bot.adapters.queue.get_next_reviewer(state, skip_usernames=skip_set) if not next_reviewer: return (f"❌ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue."), False assignment_attempt, _failure_comment = _apply_assignment_side_effects( diff --git a/scripts/reviewer_bot_lib/comment_routing.py b/scripts/reviewer_bot_lib/comment_routing.py index ab61c22f5..7bfdac96e 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -89,7 +89,7 @@ def _comment_line_is_command(bot, line: str) -> bool: def classify_comment_payload(bot, body: str) -> dict: - normalized = _normalize_comment_body(bot.adapters.review.strip_code_blocks(body)) + normalized = _normalize_comment_body(bot.adapters.commands.strip_code_blocks(body)) if not normalized: return { "comment_class": "empty_or_whitespace", @@ -102,7 +102,7 @@ def classify_comment_payload(bot, body: str) -> dict: lines = [line for line in normalized.splitlines() if line.strip()] command_lines = [line for line in lines if _comment_line_is_command(bot, line)] non_command_lines = [line for line in lines if not _comment_line_is_command(bot, line)] - parsed = bot.adapters.review.parse_command(normalized) + parsed = bot.adapters.commands.parse_command(normalized) command = None args: list[str] = [] if parsed: diff --git a/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index 430204c3a..a0ab894df 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -99,7 +99,7 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: if not any(label in bot.REVIEW_LABELS for label in labels): return False issue_author = request.issue_author - reviewer = bot.adapters.review.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) + reviewer = bot.adapters.queue.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) if not reviewer: bot.github.post_comment(issue_number, f"⚠️ No reviewers available in the queue. Please use `{bot.BOT_MENTION} /sync-members` to update the queue.") return False @@ -111,7 +111,7 @@ def handle_issue_or_pr_opened(bot, state: dict) -> bool: head_sha = request.pr_head_sha if head_sha: review_data["active_head_sha"] = head_sha - bot.adapters.review.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") + bot.adapters.queue.record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") failure_comment = bot.github.get_assignment_failure_comment(reviewer, assignment_attempt) if failure_comment: bot.github.post_comment(issue_number, failure_comment) diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index c14845b86..c0c8171de 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -35,7 +35,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: _log(bot, "warning", f"Skipping overdue evaluation for #{issue_number}; issue/PR snapshot unavailable", issue_number=issue_number) continue if isinstance(issue_snapshot.get("pull_request"), dict): - response_state = bot.adapters.review.compute_reviewer_response_state( + response_state = bot.adapters.review_state.compute_reviewer_response_state( issue_number, review_data, issue_snapshot=issue_snapshot, diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py index 986c287c8..a5c226572 100644 --- a/scripts/reviewer_bot_lib/project_board.py +++ b/scripts/reviewer_bot_lib/project_board.py @@ -265,7 +265,7 @@ def _derive_review_state( issue_snapshot: dict[str, Any], ) -> ReviewStateDerivation: preview_review_data = copy.deepcopy(review_data_snapshot) - derived = bot.adapters.review.compute_reviewer_response_state( + derived = bot.adapters.review_state.compute_reviewer_response_state( issue_number, preview_review_data, issue_snapshot=copy.deepcopy(issue_snapshot), diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index ae64e8465..a00ccbb7b 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -335,7 +335,7 @@ def reconcile_active_review_entry( return f"ℹ️ #{issue_number} is not a pull request in this event context; `/rectify` only reconciles PR reviews.", True, False if str(state.get("freshness_runtime_epoch", "")).strip() != "freshness_v15" and bot.get_config_value("IS_PULL_REQUEST", "false").lower() == "true": return "ℹ️ PR review freshness rectify is epoch-gated and currently inactive.", True, False - head_repair_result = bot.adapters.review.maybe_record_head_observation_repair(issue_number, review_data) + head_repair_result = bot.adapters.review_state.maybe_record_head_observation_repair(issue_number, review_data) state_changed = head_repair_result.changed try: reviews = _read_reconcile_reviews(bot, issue_number) @@ -968,7 +968,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: else: live_commit_id = parsed_payload.source_commit_id actor = context.actor_login - state_changed = bot.adapters.review.maybe_record_head_observation_repair(pr_number, review_data).changed + state_changed = bot.adapters.review_state.maybe_record_head_observation_repair(pr_number, review_data).changed if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer", "").lower() == actor.lower() and isinstance(live_commit_id, str) and isinstance(live_submitted_at, str): accept_channel_event( review_data, @@ -1000,7 +1000,7 @@ def handle_workflow_run_event(bot, state: dict) -> bool: timestamp=_now_iso(bot), dismissal_only=True, ) - state_changed = bot.adapters.review.maybe_record_head_observation_repair(pr_number, review_data).changed + state_changed = bot.adapters.review_state.maybe_record_head_observation_repair(pr_number, review_data).changed if _record_review_rebuild(bot, state, pr_number, review_data): state_changed = True _mark_reconciled_source_event(review_data, source_event_key) diff --git a/tests/unit/reviewer_bot/test_project_board.py b/tests/unit/reviewer_bot/test_project_board.py index 0dab3ef67..060d4fc3a 100644 --- a/tests/unit/reviewer_bot/test_project_board.py +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -113,7 +113,7 @@ def test_preview_board_projection_formats_dates_at_day_granularity(monkeypatch): routes.add_pull_request_reviews(42, []) runtime = _runtime(monkeypatch, routes) runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open", is_pull_request=True) - runtime.adapters.review.compute_reviewer_response_state = lambda issue_number, review_data, **kwargs: { + runtime.adapters.review_state.compute_reviewer_response_state = lambda issue_number, review_data, **kwargs: { "state": "awaiting_reviewer_response", "anchor_timestamp": "2026-03-21T08:00:00Z", } From 1046d8e5aefc72b8ee4a8d4a9fc5f66ca404d3a0 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 00:20:23 +0900 Subject: [PATCH 202/209] test(reviewer-bot): move handler default ownership into focused fake services --- .../test_fake_runtime_contract.py | 9 +++++++++ tests/fixtures/fake_runtime.py | 18 ++--------------- tests/fixtures/focused_fake_services.py | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/tests/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py index 8282ce77d..969efc84e 100644 --- a/tests/contract/reviewer_bot/test_fake_runtime_contract.py +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -14,6 +14,7 @@ StateStoreStub, TouchTrackerStub, WorkflowBehaviorStub, + build_default_handler_map, ) from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi @@ -235,3 +236,11 @@ def test_fake_runtime_exposes_compatibility_groups_for_thin_delegation(monkeypat assert hasattr(runtime.compat, "review") assert hasattr(runtime.compat, "state_lock") assert hasattr(runtime.compat, "automation") + + +def test_fake_runtime_default_handlers_are_built_from_focused_fake_service_helper(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + + expected = build_default_handler_map(runtime) + + assert set(expected) == HandlerStub.ALLOWED diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index 608b7780f..cda6ac6db 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -9,12 +9,10 @@ from scripts.reviewer_bot_lib import automation as automation_module from scripts.reviewer_bot_lib import commands as commands_module -from scripts.reviewer_bot_lib import comment_routing as comment_routing_module from scripts.reviewer_bot_lib import config as config_module from scripts.reviewer_bot_lib import github_api as github_api_module from scripts.reviewer_bot_lib import lease_lock as lease_lock_module from scripts.reviewer_bot_lib import lifecycle as lifecycle_module -from scripts.reviewer_bot_lib import maintenance as maintenance_module from scripts.reviewer_bot_lib import queue as queue_module from scripts.reviewer_bot_lib import reconcile as reconcile_module from scripts.reviewer_bot_lib import review_state as review_state_module @@ -51,6 +49,7 @@ StateStoreStub, TouchTrackerStub, WorkflowBehaviorStub, + build_default_handler_map, ) from tests.fixtures.recording_logger import RecordingLogger @@ -423,20 +422,7 @@ def __init__(self, monkeypatch, *, github=None): automation=FakeRuntimeAutomationCompatibility(self), ) self.adapters = FakeRuntimeAdapterServices(self) - self.handlers = HandlerStub( - { - "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(self, state), - "handle_labeled_event": lambda state: lifecycle_module.handle_labeled_event(self, state), - "handle_issue_edited_event": lambda state: lifecycle_module.handle_issue_edited_event(self, state), - "handle_closed_event": lambda state: lifecycle_module.handle_closed_event(self, state), - "handle_pull_request_target_synchronize": lambda state: lifecycle_module.handle_pull_request_target_synchronize(self, state), - "handle_pull_request_review_event": lambda state: lifecycle_module.handle_pull_request_review_event(self, state), - "handle_comment_event": lambda state: comment_routing_module.handle_comment_event(self, state), - "handle_manual_dispatch": lambda state: maintenance_module.handle_manual_dispatch(self, state), - "handle_scheduled_check": lambda state: maintenance_module.handle_scheduled_check(self, state), - "handle_workflow_run_event": lambda state: reconcile_module.handle_workflow_run_event(self, state), - } - ) + self.handlers = HandlerStub(build_default_handler_map(self)) self.infra = FakeRuntimeInfraServices( config=self.config, outputs=self.outputs, diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index bfd9c8fc6..8fed8d88e 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -485,3 +485,23 @@ def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> N def stub_sync_status_labels(self, func: Callable[[dict, Any], bool]) -> None: self._sync_status_labels = func + + +def build_default_handler_map(runtime) -> dict[str, Callable[[dict], bool]]: + from scripts.reviewer_bot_lib import comment_routing as comment_routing_module + from scripts.reviewer_bot_lib import lifecycle as lifecycle_module + from scripts.reviewer_bot_lib import maintenance as maintenance_module + from scripts.reviewer_bot_lib import reconcile as reconcile_module + + return { + "handle_issue_or_pr_opened": lambda state: lifecycle_module.handle_issue_or_pr_opened(runtime, state), + "handle_labeled_event": lambda state: lifecycle_module.handle_labeled_event(runtime, state), + "handle_issue_edited_event": lambda state: lifecycle_module.handle_issue_edited_event(runtime, state), + "handle_closed_event": lambda state: lifecycle_module.handle_closed_event(runtime, state), + "handle_pull_request_target_synchronize": lambda state: lifecycle_module.handle_pull_request_target_synchronize(runtime, state), + "handle_pull_request_review_event": lambda state: lifecycle_module.handle_pull_request_review_event(runtime, state), + "handle_comment_event": lambda state: comment_routing_module.handle_comment_event(runtime, state), + "handle_manual_dispatch": lambda state: maintenance_module.handle_manual_dispatch(runtime, state), + "handle_scheduled_check": lambda state: maintenance_module.handle_scheduled_check(runtime, state), + "handle_workflow_run_event": lambda state: reconcile_module.handle_workflow_run_event(runtime, state), + } From c13314317eec810e11a30084c16c6d7974ca7de1 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 03:15:29 +0900 Subject: [PATCH 203/209] test(reviewer-bot): strengthen focused fake service ownership models --- tests/fixtures/focused_fake_services.py | 12 ++++++ .../reviewer_bot/test_fixture_contracts.py | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/fixtures/focused_fake_services.py b/tests/fixtures/focused_fake_services.py index 8fed8d88e..651d7e4f7 100644 --- a/tests/fixtures/focused_fake_services.py +++ b/tests/fixtures/focused_fake_services.py @@ -42,6 +42,8 @@ class StateStoreStub: def __init__(self): self._load: Callable[..., dict] = lambda *, fail_on_unavailable=False: {"active_reviews": {}} self._save: Callable[[dict], bool] = lambda state: True + self.load_calls: list[dict[str, Any]] = [] + self.save_calls: list[dict] = [] def stub_load(self, func: Callable[..., dict]) -> None: self._load = func @@ -77,9 +79,11 @@ def fake_save_state(state: dict) -> bool: self.stub_save(fake_save_state) def load_state(self, *, fail_on_unavailable: bool = False) -> dict: + self.load_calls.append({"fail_on_unavailable": fail_on_unavailable}) return self._load(fail_on_unavailable=fail_on_unavailable) def save_state(self, state: dict) -> bool: + self.save_calls.append(json.loads(json.dumps(state))) return self._save(state) @@ -88,6 +92,7 @@ def __init__(self): self._acquire: Callable[[], Any] = lambda: None self._release: Callable[[], bool] = lambda: True self._refresh: Callable[[], bool] = lambda: True + self.calls: list[str] = [] def stub(self, *, acquire=None, release=None, refresh=None) -> None: if acquire is not None: @@ -98,12 +103,15 @@ def stub(self, *, acquire=None, release=None, refresh=None) -> None: self._refresh = refresh def acquire(self): + self.calls.append("acquire") return self._acquire() def release(self) -> bool: + self.calls.append("release") return self._release() def refresh(self) -> bool: + self.calls.append("refresh") return self._refresh() @@ -467,14 +475,18 @@ def __init__(self): self._process_pass_until: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_members: Callable[[dict], tuple[dict, list[str]]] = lambda state: (state, []) self._sync_status_labels: Callable[[dict, Any], bool] = lambda state, issue_numbers: False + self.calls: list[dict[str, Any]] = [] def process_pass_until_expirations(self, state: dict): + self.calls.append({"name": "process_pass_until_expirations", "state": deepcopy(state)}) return self._process_pass_until(state) def sync_members_with_queue(self, state: dict): + self.calls.append({"name": "sync_members_with_queue", "state": deepcopy(state)}) return self._sync_members(state) def sync_status_labels_for_items(self, state: dict, issue_numbers): + self.calls.append({"name": "sync_status_labels_for_items", "state": deepcopy(state), "issue_numbers": list(issue_numbers)}) return self._sync_status_labels(state, issue_numbers) def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: diff --git a/tests/unit/reviewer_bot/test_fixture_contracts.py b/tests/unit/reviewer_bot/test_fixture_contracts.py index 9ed97b7e0..8fc20cc90 100644 --- a/tests/unit/reviewer_bot/test_fixture_contracts.py +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -8,7 +8,10 @@ ArtifactDownloadTransportStub, GitHubStub, GraphQLTransportStub, + LockStub, RestTransportStub, + StateStoreStub, + WorkflowBehaviorStub, ) from tests.fixtures.recording_logger import RecordingLogger from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result @@ -112,3 +115,41 @@ def test_artifact_download_transport_stub_replays_sequence_and_raises_exceptions assert transport.download("https://example.com/artifact.zip") == {"ok": True} assert transport.download("https://example.com/artifact.zip") == {"ok": True} + + +def test_state_store_stub_records_load_and_save_calls(): + store = StateStoreStub() + store.stub_load(lambda *, fail_on_unavailable=False: {"active_reviews": {"42": {}}}) + store.stub_save(lambda state: True) + + assert store.load_state(fail_on_unavailable=True) == {"active_reviews": {"42": {}}} + assert store.save_state({"active_reviews": {"42": {"current_reviewer": "alice"}}}) is True + assert store.load_calls == [{"fail_on_unavailable": True}] + assert store.save_calls == [{"active_reviews": {"42": {"current_reviewer": "alice"}}}] + + +def test_lock_stub_records_acquire_release_refresh_calls(): + lock = LockStub() + lock.stub(acquire=lambda: "token", release=lambda: True, refresh=lambda: False) + + assert lock.acquire() == "token" + assert lock.refresh() is False + assert lock.release() is True + assert lock.calls == ["acquire", "refresh", "release"] + + +def test_workflow_behavior_stub_records_behavior_calls(): + workflow = WorkflowBehaviorStub() + workflow.stub_pass_until(lambda state: ({**state, "restored": True}, ["alice"])) + workflow.stub_sync_members(lambda state: ({**state, "synced": True}, ["bob"])) + workflow.stub_sync_status_labels(lambda state, issue_numbers: True) + + state = {"active_reviews": {}} + assert workflow.process_pass_until_expirations(state) == ({"active_reviews": {}, "restored": True}, ["alice"]) + assert workflow.sync_members_with_queue(state) == ({"active_reviews": {}, "synced": True}, ["bob"]) + assert workflow.sync_status_labels_for_items(state, [42, 99]) is True + assert [call["name"] for call in workflow.calls] == [ + "process_pass_until_expirations", + "sync_members_with_queue", + "sync_status_labels_for_items", + ] From 90d1220a5d46cbbfcf794904bc396e18300fd8fa Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 03:30:17 +0900 Subject: [PATCH 204/209] refactor(reviewer-bot): remove remaining optional runtime-owned infra fallbacks --- scripts/reviewer_bot_lib/context.py | 8 +++++++- scripts/reviewer_bot_lib/github_api.py | 19 ++++--------------- scripts/reviewer_bot_lib/sweeper.py | 21 ++++----------------- tests/fixtures/fake_runtime.py | 18 +++++++++++++++++- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 1485dd222..fa8a0db0a 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -269,12 +269,16 @@ class GitHubApiContext(Protocol): AssignmentAttempt: type[AssignmentAttempt] GitHubApiResult: type[GitHubApiResult] logger: Logger + sleeper: Sleeper + jitter: JitterSource rest_transport: RestTransport graphql_transport: GraphQLTransport def get_config_value(self, name: str, default: str = "") -> str: ... def get_github_token(self) -> str: ... def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: ... + def lock_api_retry_limit(self) -> int: ... + def lock_retry_base_seconds(self) -> float: ... @runtime_checkable @@ -430,7 +434,6 @@ def renew_state_issue_lease_lock(self, context: LeaseContext) -> bool: ... @runtime_checkable class SweeperContext(Protocol): - LOCK_RETRY_BASE_SECONDS: float STATUS_PROJECTION_EPOCH: str TRANSITION_PERIOD_DAYS: int DEFERRED_DISCOVERY_OVERLAP_SECONDS: int @@ -438,6 +441,7 @@ class SweeperContext(Protocol): REVIEW_FRESHNESS_RUNBOOK_PATH: str GitHubApiResult: type[GitHubApiResult] artifact_download_transport: ArtifactDownloadTransport + sleeper: Sleeper jitter: JitterSource logger: Logger @@ -467,6 +471,8 @@ def compute_reviewer_response_state( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> dict[str, object]: ... + def lock_api_retry_limit(self) -> int: ... + def lock_retry_base_seconds(self) -> float: ... @runtime_checkable diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 5bbbddfa2..c8303e1bd 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -2,7 +2,6 @@ import json import random -import time from urllib.parse import quote import requests @@ -30,18 +29,14 @@ def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: def _sleep(bot: GitHubApiContext, seconds: float) -> None: - sleeper = getattr(bot, "sleeper", None) - if sleeper is not None and hasattr(sleeper, "sleep"): - sleeper.sleep(seconds) - return - time.sleep(seconds) + bot.sleeper.sleep(seconds) def _retry_delay(bot: GitHubApiContext, base_seconds: float, retry_attempt: int) -> float: return retrying.bounded_exponential_delay( base_seconds, retry_attempt, - jitter=getattr(bot, "jitter", None) or _RandomJitter(), + jitter=bot.jitter, ) @@ -480,8 +475,8 @@ def request_reviewer_assignment(bot: GitHubTransportContext, issue_number: int, payload = {"assignees": [username]} assignment_target = "issue assignee" - lock_api_retry_limit = getattr(bot, "LOCK_API_RETRY_LIMIT", LOCK_API_RETRY_LIMIT) - lock_retry_base_seconds = getattr(bot, "LOCK_RETRY_BASE_SECONDS", LOCK_RETRY_BASE_SECONDS) + lock_api_retry_limit = bot.lock_api_retry_limit() + lock_retry_base_seconds = bot.lock_retry_base_seconds() for attempt in range(1, lock_api_retry_limit + 1): response = bot.github_api_request("POST", endpoint, payload, suppress_error_log=True) @@ -619,12 +614,6 @@ def get_user_permission_status( retry_policy=RETRY_POLICY_IDEMPOTENT_READ, ) except SystemExit: - fallback = getattr(bot, "check_user_permission", None) - if callable(fallback) and getattr(fallback, "__name__", "") != "check_user_permission": - result = fallback(username, required_permission) - if result is None: - return "unavailable" - return "granted" if result else "denied" return "unavailable" if not response.ok: return "unavailable" diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index f2f3432ce..07ee7624d 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -4,7 +4,6 @@ import io import json -import time import zipfile from datetime import datetime, timedelta, timezone from typing import Any @@ -71,24 +70,12 @@ def _read_api_payload(bot: SweeperContext, endpoint: str) -> tuple[Any | None, s def _download_retry_delay(bot: SweeperContext, retry_attempt: int) -> float: - base = float(getattr(bot, "LOCK_RETRY_BASE_SECONDS", 2.0)) - - class _BotJitter: - def uniform(self, lower: float, upper: float) -> float: - jitter = getattr(bot, "jitter", None) - if jitter is not None and hasattr(jitter, "uniform"): - return jitter.uniform(lower, upper) - return __import__("random").uniform(lower, upper) - - return retrying.bounded_exponential_delay(base, retry_attempt, jitter=_BotJitter()) + base = float(bot.lock_retry_base_seconds()) + return retrying.bounded_exponential_delay(base, retry_attempt, jitter=bot.jitter) def _sleep(bot: SweeperContext, seconds: float) -> None: - sleeper = getattr(bot, "sleeper", None) - if sleeper is not None and hasattr(sleeper, "sleep"): - sleeper.sleep(seconds) - return - time.sleep(seconds) + bot.sleeper.sleep(seconds) def observer_run_reason_from_details(run_details: dict, runbook_signature: dict | None) -> str: @@ -315,7 +302,7 @@ def _download_artifact_payload(bot: SweeperContext, artifact: dict, expected_pay download_url = artifact.get("archive_download_url") if not isinstance(download_url, str) or not download_url: return "missing_download_url", None - max_attempts = int(getattr(bot, "LOCK_API_RETRY_LIMIT", 5)) + 1 + max_attempts = int(bot.lock_api_retry_limit()) + 1 response = None for attempt in range(1, max_attempts + 1): try: diff --git a/tests/fixtures/fake_runtime.py b/tests/fixtures/fake_runtime.py index cda6ac6db..6a35ce6f6 100644 --- a/tests/fixtures/fake_runtime.py +++ b/tests/fixtures/fake_runtime.py @@ -36,6 +36,10 @@ GitHubApiResult, ) from scripts.reviewer_bot_lib.context import LeaseContext +from tests.fixtures.fake_clock import FakeClock +from tests.fixtures.fake_jitter import DeterministicJitter +from tests.fixtures.fake_sleeper import RecordingSleeper +from tests.fixtures.fake_uuid import FixedUuidSource from tests.fixtures.focused_fake_services import ( ArtifactDownloadTransportStub, ConfigBag, @@ -55,7 +59,7 @@ class FakeRuntimeInfraServices: - def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport, graphql_transport, artifact_download_transport, touch_tracker): + def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport, graphql_transport, artifact_download_transport, clock, sleeper, jitter, uuid_source, touch_tracker): self.config = config self.outputs = outputs self.deferred_payloads = deferred_payloads @@ -63,6 +67,10 @@ def __init__(self, *, config, outputs, deferred_payloads, logger, rest_transport self.rest_transport = rest_transport self.graphql_transport = graphql_transport self.artifact_download_transport = artifact_download_transport + self.clock = clock + self.sleeper = sleeper + self.jitter = jitter + self.uuid_source = uuid_source self.touch_tracker = touch_tracker @@ -394,6 +402,10 @@ def __init__(self, monkeypatch, *, github=None): self.sys = sys self.random = random self.time = time + self.clock = FakeClock(datetime(2026, 1, 1, tzinfo=timezone.utc)) + self.sleeper = RecordingSleeper() + self.jitter = DeterministicJitter(0.0) + self.uuid_source = FixedUuidSource("fake-runtime-uuid") self.logger = RecordingLogger() self.ACTIVE_LEASE_CONTEXT = LeaseContext( lock_token="test-lock-token", @@ -431,6 +443,10 @@ def __init__(self, monkeypatch, *, github=None): rest_transport=self.rest_transport, graphql_transport=self.graphql_transport, artifact_download_transport=self.artifact_download_transport, + clock=self.clock, + sleeper=self.sleeper, + jitter=self.jitter, + uuid_source=self.uuid_source, touch_tracker=self.touch_tracker, ) self.domain = FakeRuntimeDomainServices( From 507434bd8237cf10b3bfe1b669970df4516b506a Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 03:31:16 +0900 Subject: [PATCH 205/209] refactor(reviewer-bot): require logger and clock in orchestration helpers --- scripts/reviewer_bot_lib/app.py | 7 +------ scripts/reviewer_bot_lib/reconcile.py | 5 +---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 02bcfe72c..9f2d91458 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -16,12 +16,7 @@ def _log(bot: AppExecutionRuntime, level: str, message: str, **fields) -> None: - logger = getattr(bot, "logger", None) - if logger is not None and hasattr(logger, "event"): - logger.event(level, message, **fields) - return - stream = sys.stderr if level in {"warning", "error"} else sys.stdout - stream.write(f"{message}\n") + bot.logger.event(level, message, **fields) def _revalidate_epoch(bot: AppExecutionRuntime, expected_epoch: str | None, phase: str) -> None: diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index a00ccbb7b..f641d2d5a 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -29,10 +29,7 @@ def _log(bot, level: str, message: str, **fields) -> None: def _now_iso(bot) -> str: - clock = getattr(bot, "clock", None) - if clock is not None and hasattr(clock, "now"): - return clock.now().isoformat() - return bot.datetime.now(bot.timezone.utc).isoformat() + return bot.clock.now().isoformat() class ReconcileReadError(RuntimeError): From 753471682721cf845df677b0bffa75c73e4891e5 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 03:39:55 +0900 Subject: [PATCH 206/209] refactor(reviewer-bot): separate state and lock codec ownership --- scripts/reviewer_bot_lib/lease_lock.py | 22 ++----- scripts/reviewer_bot_lib/lock_codec.py | 77 ++++++++++++++++++++++ scripts/reviewer_bot_lib/state_store.py | 45 ++----------- tests/unit/reviewer_bot/test_lock_codec.py | 37 +++++++++++ 4 files changed, 125 insertions(+), 56 deletions(-) create mode 100644 scripts/reviewer_bot_lib/lock_codec.py create mode 100644 tests/unit/reviewer_bot/test_lock_codec.py diff --git a/scripts/reviewer_bot_lib/lease_lock.py b/scripts/reviewer_bot_lib/lease_lock.py index edcba068b..428e7aee3 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -1,12 +1,10 @@ """Reviewer-bot lease lock helpers.""" -import json from datetime import datetime, timezone from typing import Any -from . import retrying +from . import lock_codec, retrying from .config import ( - LOCK_COMMIT_MARKER, LOCK_REF_NAME, LeaseContext, ) @@ -106,7 +104,7 @@ def get_lock_owner_context(bot: LeaseLockContext) -> tuple[str, str, str]: def build_lock_metadata(bot: LeaseLockContext, lock_token: str, lock_owner_run_id: str, lock_owner_workflow: str, lock_owner_job: str) -> dict: acquired_at = _now(bot) expires_at = acquired_at.timestamp() + _lock_lease_ttl_seconds(bot) - return bot.normalize_lock_metadata( + return lock_codec.normalize_lock_metadata( { "schema_version": 1, "lock_state": "locked", @@ -121,7 +119,7 @@ def build_lock_metadata(bot: LeaseLockContext, lock_token: str, lock_owner_run_i def clear_lock_metadata(bot: LeaseLockContext) -> dict: - return bot.normalize_lock_metadata({"lock_state": "unlocked"}) + return lock_codec.normalize_lock_metadata({"lock_state": "unlocked"}) def normalize_lock_ref_name(ref_name: str) -> str: @@ -230,19 +228,13 @@ def _snapshot_is_stale_unlocked_predecessor(current_lock: dict) -> bool: def render_lock_commit_message(bot: LeaseLockContext, lock_meta: dict) -> str: - lock_json = json.dumps(bot.normalize_lock_metadata(lock_meta), sort_keys=False) - return f"{LOCK_COMMIT_MARKER}\n{lock_json}" + del bot + return lock_codec.render_lock_commit_message(lock_meta) def parse_lock_metadata_from_lock_commit_message(bot: LeaseLockContext, message: str) -> dict: - if not message.startswith(f"{LOCK_COMMIT_MARKER}\n"): - return bot.clear_lock_metadata() - lock_json = message.split("\n", 1)[1] - try: - parsed = json.loads(lock_json) - except json.JSONDecodeError: - return bot.clear_lock_metadata() - return bot.normalize_lock_metadata(parsed if isinstance(parsed, dict) else None) + del bot + return lock_codec.parse_lock_commit_message(message) def ensure_lock_ref_exists(bot: LeaseLockContext) -> str: diff --git a/scripts/reviewer_bot_lib/lock_codec.py b/scripts/reviewer_bot_lib/lock_codec.py new file mode 100644 index 000000000..667d1a5ad --- /dev/null +++ b/scripts/reviewer_bot_lib/lock_codec.py @@ -0,0 +1,77 @@ +"""Lock metadata codec helpers for reviewer-bot state and lock documents.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from .config import ( + LOCK_BLOCK_END_MARKER, + LOCK_BLOCK_START_MARKER, + LOCK_COMMIT_MARKER, + LOCK_METADATA_KEYS, + LOCK_SCHEMA_VERSION, +) + + +def normalize_lock_metadata(lock_meta: dict | None) -> dict: + normalized: dict[str, Any] = dict.fromkeys(LOCK_METADATA_KEYS) + normalized["schema_version"] = LOCK_SCHEMA_VERSION + + if not isinstance(lock_meta, dict): + return normalized + + for key in LOCK_METADATA_KEYS: + if key == "schema_version": + schema_value = lock_meta.get("schema_version") + if isinstance(schema_value, int): + normalized["schema_version"] = schema_value + continue + if key in lock_meta: + normalized[key] = lock_meta.get(key) + + return normalized + + +def extract_fenced_json(inner_block: str | None) -> str | None: + if not inner_block: + return None + match = re.search(r"```json\n(.*?)\n```", inner_block, re.DOTALL) + if match: + return match.group(1) + return None + + +def parse_lock_metadata_block(inner_block: str | None) -> dict: + lock_json = extract_fenced_json(inner_block) + if lock_json is None: + return normalize_lock_metadata(None) + try: + parsed = json.loads(lock_json) + except json.JSONDecodeError: + return normalize_lock_metadata(None) + if not isinstance(parsed, dict): + return normalize_lock_metadata(None) + return normalize_lock_metadata(parsed) + + +def render_marked_lock_block(lock_meta: dict) -> str: + lock_json = json.dumps(normalize_lock_metadata(lock_meta), indent=2, sort_keys=False).rstrip("\n") + return f"{LOCK_BLOCK_START_MARKER}\n```json\n{lock_json}\n```\n{LOCK_BLOCK_END_MARKER}" + + +def render_lock_commit_message(lock_meta: dict) -> str: + lock_json = json.dumps(normalize_lock_metadata(lock_meta), sort_keys=False) + return f"{LOCK_COMMIT_MARKER}\n{lock_json}" + + +def parse_lock_commit_message(message: str) -> dict: + if not message.startswith(f"{LOCK_COMMIT_MARKER}\n"): + return normalize_lock_metadata({"lock_state": "unlocked"}) + lock_json = message.split("\n", 1)[1] + try: + parsed = json.loads(lock_json) + except json.JSONDecodeError: + return normalize_lock_metadata({"lock_state": "unlocked"}) + return normalize_lock_metadata(parsed if isinstance(parsed, dict) else None) diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 76c99e969..546f3eaf5 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -1,19 +1,16 @@ """State issue parsing, loading, and saving helpers.""" -import json import re from datetime import datetime, timezone from typing import Any import yaml -from . import retrying +from . import lock_codec, retrying from .config import ( FRESHNESS_RUNTIME_EPOCH_LEGACY, LOCK_BLOCK_END_MARKER, LOCK_BLOCK_START_MARKER, - LOCK_METADATA_KEYS, - LOCK_SCHEMA_VERSION, STATE_BLOCK_END_MARKER, STATE_BLOCK_START_MARKER, STATE_SCHEMA_VERSION, @@ -204,22 +201,7 @@ def extract_fenced_block(inner_block: str, language_pattern: str) -> str | None: def normalize_lock_metadata(lock_meta: dict | None) -> dict: - normalized: dict[str, Any] = dict.fromkeys(LOCK_METADATA_KEYS) - normalized["schema_version"] = LOCK_SCHEMA_VERSION - - if not isinstance(lock_meta, dict): - return normalized - - for key in LOCK_METADATA_KEYS: - if key == "schema_version": - schema_value = lock_meta.get("schema_version") - if isinstance(schema_value, int): - normalized["schema_version"] = schema_value - continue - if key in lock_meta: - normalized[key] = lock_meta.get(key) - - return normalized + return lock_codec.normalize_lock_metadata(lock_meta) def parse_state_yaml_from_issue_body(body: str) -> dict: @@ -250,20 +232,7 @@ def parse_lock_metadata_from_issue_body(body: str) -> dict: parts = split_state_issue_body(body) if not parts.has_lock_markers or parts.lock_block_inner is None: return normalize_lock_metadata(None) - - lock_json = extract_fenced_block(parts.lock_block_inner, "json") - if lock_json is None: - return normalize_lock_metadata(None) - - try: - parsed = json.loads(lock_json) - except json.JSONDecodeError: - return normalize_lock_metadata(None) - - if not isinstance(parsed, dict): - return normalize_lock_metadata(None) - - return normalize_lock_metadata(parsed) + return lock_codec.parse_lock_metadata_block(parts.lock_block_inner) def render_marked_fenced_block(start_marker: str, end_marker: str, language: str, content: str) -> str: @@ -296,13 +265,7 @@ def render_state_issue_body( yaml_content, ) - lock_json = json.dumps(normalize_lock_metadata(lock_meta), indent=2, sort_keys=False) - lock_section = render_marked_fenced_block( - LOCK_BLOCK_START_MARKER, - LOCK_BLOCK_END_MARKER, - "json", - lock_json, - ) + lock_section = lock_codec.render_marked_lock_block(lock_meta) prefix = parts.prefix or default_state_issue_prefix() between = parts.between_state_and_lock if parts.has_state_markers and parts.has_lock_markers else "\n\n" diff --git a/tests/unit/reviewer_bot/test_lock_codec.py b/tests/unit/reviewer_bot/test_lock_codec.py new file mode 100644 index 000000000..43987361e --- /dev/null +++ b/tests/unit/reviewer_bot/test_lock_codec.py @@ -0,0 +1,37 @@ +from scripts.reviewer_bot_lib import lock_codec + + +def test_lock_codec_round_trips_marked_issue_block_metadata(): + lock_meta = { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "run-1", + } + + rendered = lock_codec.render_marked_lock_block(lock_meta) + parsed = lock_codec.parse_lock_metadata_block(rendered) + + assert parsed["lock_state"] == "locked" + assert parsed["lock_token"] == "token-123" + assert parsed["lock_owner_run_id"] == "run-1" + + +def test_lock_codec_round_trips_commit_message_metadata(): + lock_meta = { + "lock_state": "locked", + "lock_token": "token-123", + "lock_owner_run_id": "run-1", + } + + rendered = lock_codec.render_lock_commit_message(lock_meta) + parsed = lock_codec.parse_lock_commit_message(rendered) + + assert parsed["lock_state"] == "locked" + assert parsed["lock_token"] == "token-123" + assert parsed["lock_owner_run_id"] == "run-1" + + +def test_lock_codec_returns_unlocked_metadata_for_invalid_commit_message(): + parsed = lock_codec.parse_lock_commit_message("not a lock commit") + + assert parsed["lock_state"] == "unlocked" From a9bdd7ab7ea9c99406eb9456f3551135cf610fcf Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 04:01:15 +0900 Subject: [PATCH 207/209] refactor(reviewer-bot): split reconcile by payload and replay concern --- scripts/reviewer_bot_lib/reconcile.py | 423 ++---------------- .../reviewer_bot_lib/reconcile_payloads.py | 364 +++++++++++++++ scripts/reviewer_bot_lib/reconcile_reads.py | 110 +++++ 3 files changed, 505 insertions(+), 392 deletions(-) create mode 100644 scripts/reviewer_bot_lib/reconcile_payloads.py create mode 100644 scripts/reviewer_bot_lib/reconcile_reads.py diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index f641d2d5a..3c09b1910 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -12,6 +12,37 @@ ) from .comment_routing import classify_comment_payload, classify_issue_comment_actor from .context import CommentEventRequest +from .reconcile_payloads import ( + DeferredCommentPayload, + DeferredCommentReplayContext, + DeferredReviewPayload, + ObserverNoopPayload, + build_deferred_comment_replay_context, + build_deferred_review_replay_context, + parse_deferred_context_payload, +) +from .reconcile_payloads import ( + expected_observer_identity as _expected_observer_identity, +) +from .reconcile_reads import ( + LiveCommentReplayContext, + ReconcileReadError, +) +from .reconcile_reads import ( + read_live_comment_replay_context as _read_live_comment_replay_context, +) +from .reconcile_reads import ( + read_live_pr_replay_context as _read_live_pr_replay_context, +) +from .reconcile_reads import ( + read_optional_reconcile_object as _read_optional_reconcile_object, +) +from .reconcile_reads import ( + read_reconcile_object as _read_reconcile_object, +) +from .reconcile_reads import ( + read_reconcile_reviews as _read_reconcile_reviews, +) from .review_state import ( accept_channel_event, ensure_review_entry, @@ -32,12 +63,6 @@ def _now_iso(bot) -> str: return bot.clock.now().isoformat() -class ReconcileReadError(RuntimeError): - def __init__(self, message: str, *, failure_kind: str | None = None): - super().__init__(message) - self.failure_kind = failure_kind - - @dataclass(frozen=True) class LiveCommentReplayValidationResult: live_classified: dict | None @@ -45,200 +70,6 @@ class LiveCommentReplayValidationResult: failed_closed: bool -@dataclass(frozen=True) -class DeferredArtifactIdentity: - schema_version: int - source_workflow_name: str - source_workflow_file: str - source_run_id: int - source_run_attempt: int - source_event_name: str - source_event_action: str - source_event_key: str - - -@dataclass(frozen=True) -class LivePrReplayContext: - issue_author: str - issue_labels: tuple[str, ...] - - -@dataclass(frozen=True) -class LiveCommentReplayContext: - comment_author: str - comment_user_type: str - comment_author_association: str - comment_sender_type: str - comment_installation_id: str - comment_performed_via_github_app: bool - - -@dataclass(frozen=True) -class DeferredReviewPayload: - identity: DeferredArtifactIdentity - pr_number: int - review_id: int - source_submitted_at: str | None - source_review_state: str | None - source_commit_id: str | None - actor_login: str | None - raw_payload: dict - - -@dataclass(frozen=True) -class DeferredCommentPayload: - identity: DeferredArtifactIdentity - pr_number: int - comment_id: int - comment_class: str - has_non_command_text: bool - source_body_digest: str - source_created_at: str - actor_login: str | None - raw_payload: dict - - -@dataclass(frozen=True) -class ObserverNoopPayload: - identity: DeferredArtifactIdentity - pr_number: int - reason: str - raw_payload: dict - - -@dataclass(frozen=True) -class DeferredCommentReplayContext: - payload: DeferredCommentPayload - expected_event_name: str - live_comment_endpoint: str - - @property - def source_event_key(self) -> str: - return self.payload.identity.source_event_key - - @property - def comment_id(self) -> int: - return self.payload.comment_id - - @property - def pr_number(self) -> int: - return self.payload.pr_number - - @property - def actor_login(self) -> str: - return self.payload.actor_login or "" - - @property - def source_created_at(self) -> str: - return self.payload.source_created_at - - @property - def source_freshness_eligible(self) -> bool: - return self.payload.comment_class in {"plain_text", "command_plus_text"} and self.payload.has_non_command_text - - -@dataclass(frozen=True) -class DeferredReviewReplayContext: - payload: DeferredReviewPayload - - @property - def source_event_key(self) -> str: - return self.payload.identity.source_event_key - - @property - def review_id(self) -> int: - return self.payload.review_id - - @property - def pr_number(self) -> int: - return self.payload.pr_number - - @property - def actor_login(self) -> str: - return self.payload.actor_login or "" - - -def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: - return DeferredArtifactIdentity( - schema_version=int(payload["schema_version"]), - source_workflow_name=str(payload["source_workflow_name"]), - source_workflow_file=str(payload["source_workflow_file"]), - source_run_id=int(payload["source_run_id"]), - source_run_attempt=int(payload["source_run_attempt"]), - source_event_name=str(payload["source_event_name"]), - source_event_action=str(payload["source_event_action"]), - source_event_key=str(payload["source_event_key"]), - ) - - -def build_deferred_comment_replay_context( - payload: DeferredCommentPayload, - *, - expected_event_name: str, - live_comment_endpoint: str, -) -> DeferredCommentReplayContext: - if payload.identity.source_event_key != f"{expected_event_name}:{payload.comment_id}": - raise RuntimeError("Deferred comment artifact source_event_key mismatch") - return DeferredCommentReplayContext( - payload=payload, - expected_event_name=expected_event_name, - live_comment_endpoint=live_comment_endpoint, - ) - - -def build_deferred_review_replay_context( - payload: DeferredReviewPayload, - *, - expected_event_action: str, -) -> DeferredReviewReplayContext: - expected_prefix = ( - "pull_request_review:" - if expected_event_action == "submitted" - else "pull_request_review_dismissed:" - ) - if payload.identity.source_event_action != expected_event_action: - raise RuntimeError("Deferred review artifact action mismatch") - if payload.identity.source_event_key != f"{expected_prefix}{payload.review_id}": - raise RuntimeError(f"Deferred review-{expected_event_action} artifact source_event_key mismatch") - return DeferredReviewReplayContext(payload=payload) - - -def _read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: - try: - response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") - except SystemExit: - payload = bot.github_api("GET", endpoint) - if not isinstance(payload, dict): - raise ReconcileReadError(f"{label} unavailable", failure_kind="unavailable") - return payload - if not response.ok: - failure_kind = response.failure_kind - if failure_kind == "not_found": - raise ReconcileReadError(f"{label} not found", failure_kind=failure_kind) - raise ReconcileReadError(f"{label} unavailable", failure_kind=failure_kind) - if not isinstance(response.payload, dict): - raise ReconcileReadError(f"{label} payload invalid", failure_kind="invalid_payload") - return response.payload - - -def _read_optional_reconcile_object(bot, endpoint: str, *, label: str) -> dict | None: - try: - return _read_reconcile_object(bot, endpoint, label=label) - except ReconcileReadError as exc: - if exc.failure_kind == "not_found": - return None - raise - - -def _read_reconcile_reviews(bot, issue_number: int) -> list[dict]: - reviews = bot.github.get_pull_request_reviews(issue_number) - if reviews is None: - raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") - if not isinstance(reviews, list): - raise ReconcileReadError(f"live reviews for PR #{issue_number} payload invalid", failure_kind="invalid_payload") - return reviews - - def _ensure_source_event_key(review_data: dict, source_event_key: str, payload: dict | None = None) -> None: review_data.setdefault("deferred_gaps", {}) if payload is None: @@ -491,168 +322,6 @@ def _load_deferred_context(bot) -> dict: return bot.load_deferred_payload() -def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | DeferredCommentPayload | ObserverNoopPayload: - if not isinstance(payload, dict): - raise RuntimeError("Deferred context payload must be a JSON object") - - if payload.get("kind") == "observer_noop": - _validate_observer_noop_payload(payload) - return ObserverNoopPayload( - identity=_build_deferred_identity(payload), - pr_number=int(payload["pr_number"]), - reason=str(payload["reason"]), - raw_payload=payload, - ) - - event_name = payload.get("source_event_name") - event_action = payload.get("source_event_action") - if event_name == "issue_comment" and event_action == "created": - _validate_deferred_comment_artifact(payload) - return DeferredCommentPayload( - identity=_build_deferred_identity(payload), - pr_number=int(payload["pr_number"]), - comment_id=int(payload["comment_id"]), - comment_class=str(payload["comment_class"]), - has_non_command_text=bool(payload["has_non_command_text"]), - source_body_digest=str(payload["source_body_digest"]), - source_created_at=str(payload["source_created_at"]), - actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), - raw_payload=payload, - ) - if event_name == "pull_request_review_comment" and event_action == "created": - _validate_deferred_review_comment_artifact(payload) - return DeferredCommentPayload( - identity=_build_deferred_identity(payload), - pr_number=int(payload["pr_number"]), - comment_id=int(payload["comment_id"]), - comment_class=str(payload["comment_class"]), - has_non_command_text=bool(payload["has_non_command_text"]), - source_body_digest=str(payload["source_body_digest"]), - source_created_at=str(payload["source_created_at"]), - actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), - raw_payload=payload, - ) - if event_name == "pull_request_review" and event_action in {"submitted", "dismissed"}: - _validate_deferred_review_artifact(payload) - return DeferredReviewPayload( - identity=_build_deferred_identity(payload), - pr_number=int(payload["pr_number"]), - review_id=int(payload["review_id"]), - source_submitted_at=( - str(payload["source_submitted_at"]) if payload.get("source_submitted_at") is not None else None - ), - source_review_state=( - str(payload["source_review_state"]) if payload.get("source_review_state") is not None else None - ), - source_commit_id=( - str(payload["source_commit_id"]) if payload.get("source_commit_id") is not None else None - ), - actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), - raw_payload=payload, - ) - raise RuntimeError("Unsupported deferred workflow_run payload") - - -def _read_live_pr_replay_context(bot, pr_number: int) -> LivePrReplayContext: - pull_request = _read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number} for reconcile context") - author = pull_request.get("user") - if not isinstance(author, dict): - raise RuntimeError(f"Live PR #{pr_number} is missing author metadata") - author_login = author.get("login") - if not isinstance(author_login, str) or not author_login.strip(): - raise RuntimeError(f"Live PR #{pr_number} is missing a valid author login") - labels = pull_request.get("labels") - if labels is None: - labels = [] - if not isinstance(labels, list): - raise RuntimeError(f"Live PR #{pr_number} labels are malformed") - label_names: list[str] = [] - for label in labels: - if not isinstance(label, dict): - raise RuntimeError(f"Live PR #{pr_number} contains malformed label metadata") - name = label.get("name") - if not isinstance(name, str): - raise RuntimeError(f"Live PR #{pr_number} contains a label without a valid name") - label_names.append(name) - return LivePrReplayContext(issue_author=author_login, issue_labels=tuple(label_names)) - - -def _read_live_comment_replay_context(live_comment: dict, payload: dict) -> LiveCommentReplayContext: - user = live_comment.get("user") - if not isinstance(user, dict): - raise RuntimeError("Live deferred comment user metadata is unavailable") - comment_author = user.get("login") or payload.get("actor_login") or "" - if not isinstance(comment_author, str) or not comment_author.strip(): - raise RuntimeError("Live deferred comment author login is unavailable") - comment_user_type = user.get("type") - if not isinstance(comment_user_type, str) or not comment_user_type.strip(): - raise RuntimeError("Live deferred comment user type is unavailable") - author_association = live_comment.get("author_association") - if not isinstance(author_association, str) or not author_association.strip(): - raise RuntimeError("Live deferred comment author association is unavailable") - return LiveCommentReplayContext( - comment_author=comment_author, - comment_user_type=comment_user_type, - comment_author_association=author_association, - comment_sender_type=comment_user_type, - comment_installation_id="", - comment_performed_via_github_app=bool(live_comment.get("performed_via_github_app")), - ) - - -def _validate_observer_noop_payload(payload: dict) -> None: - required = { - "schema_version", - "kind", - "reason", - "source_workflow_name", - "source_workflow_file", - "source_run_id", - "source_run_attempt", - "source_event_name", - "source_event_action", - "source_event_key", - "pr_number", - } - missing = sorted(required - set(payload)) - if missing: - raise RuntimeError("Observer no-op payload missing required fields: " + ", ".join(missing)) - if payload.get("schema_version") != 1: - raise RuntimeError("Observer no-op payload schema_version is not accepted") - if payload.get("kind") != "observer_noop": - raise RuntimeError("Observer no-op payload kind mismatch") - if not isinstance(payload.get("reason"), str) or not payload.get("reason"): - raise RuntimeError("Observer no-op payload reason must be a non-empty string") - if not isinstance(payload.get("pr_number"), int): - raise RuntimeError("Observer no-op payload pr_number must be an integer") - - -def _expected_observer_identity(payload: dict) -> tuple[str, str]: - event_name = payload.get("source_event_name") - event_action = payload.get("source_event_action") - if event_name == "issue_comment" and event_action == "created": - return ( - "Reviewer Bot PR Comment Observer", - ".github/workflows/reviewer-bot-pr-comment-observer.yml", - ) - if event_name == "pull_request_review" and event_action == "submitted": - return ( - "Reviewer Bot PR Review Submitted Observer", - ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", - ) - if event_name == "pull_request_review" and event_action == "dismissed": - return ( - "Reviewer Bot PR Review Dismissed Observer", - ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", - ) - if event_name == "pull_request_review_comment" and event_action == "created": - return ( - "Reviewer Bot PR Review Comment Observer", - ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", - ) - raise RuntimeError("Unsupported deferred workflow identity") - - def _validate_workflow_run_artifact_identity(bot, payload: dict) -> None: expected_name, expected_file = _expected_observer_identity(payload) if payload.get("source_workflow_name") != expected_name: @@ -672,36 +341,6 @@ def _validate_workflow_run_artifact_identity(bot, payload: dict) -> None: raise RuntimeError("Triggering observer workflow did not conclude successfully") -def _artifact_expected_name(payload: dict) -> str: - event_name = payload.get("source_event_name") - event_action = payload.get("source_event_action") - run_id = payload.get("source_run_id") - run_attempt = payload.get("source_run_attempt") - if event_name == "issue_comment" and event_action == "created": - return f"reviewer-bot-comment-context-{run_id}-attempt-{run_attempt}" - if event_name == "pull_request_review" and event_action == "submitted": - return f"reviewer-bot-review-submitted-context-{run_id}-attempt-{run_attempt}" - if event_name == "pull_request_review" and event_action == "dismissed": - return f"reviewer-bot-review-dismissed-context-{run_id}-attempt-{run_attempt}" - if event_name == "pull_request_review_comment" and event_action == "created": - return f"reviewer-bot-review-comment-context-{run_id}-attempt-{run_attempt}" - raise RuntimeError("Unsupported deferred artifact naming") - - -def _artifact_expected_payload_name(payload: dict) -> str: - event_name = payload.get("source_event_name") - event_action = payload.get("source_event_action") - if event_name == "issue_comment" and event_action == "created": - return "deferred-comment.json" - if event_name == "pull_request_review" and event_action == "submitted": - return "deferred-review-submitted.json" - if event_name == "pull_request_review" and event_action == "dismissed": - return "deferred-review-dismissed.json" - if event_name == "pull_request_review_comment" and event_action == "created": - return "deferred-review-comment.json" - raise RuntimeError("Unsupported deferred payload path") - - def _reconcile_deferred_comment( bot, state: dict, diff --git a/scripts/reviewer_bot_lib/reconcile_payloads.py b/scripts/reviewer_bot_lib/reconcile_payloads.py new file mode 100644 index 000000000..eaab9c34f --- /dev/null +++ b/scripts/reviewer_bot_lib/reconcile_payloads.py @@ -0,0 +1,364 @@ +"""Deferred reconcile payload and identity helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeferredArtifactIdentity: + schema_version: int + source_workflow_name: str + source_workflow_file: str + source_run_id: int + source_run_attempt: int + source_event_name: str + source_event_action: str + source_event_key: str + + +@dataclass(frozen=True) +class DeferredReviewPayload: + identity: DeferredArtifactIdentity + pr_number: int + review_id: int + source_submitted_at: str | None + source_review_state: str | None + source_commit_id: str | None + actor_login: str | None + raw_payload: dict + + +@dataclass(frozen=True) +class DeferredCommentPayload: + identity: DeferredArtifactIdentity + pr_number: int + comment_id: int + comment_class: str + has_non_command_text: bool + source_body_digest: str + source_created_at: str + actor_login: str | None + raw_payload: dict + + +@dataclass(frozen=True) +class ObserverNoopPayload: + identity: DeferredArtifactIdentity + pr_number: int + reason: str + raw_payload: dict + + +@dataclass(frozen=True) +class DeferredCommentReplayContext: + payload: DeferredCommentPayload + expected_event_name: str + live_comment_endpoint: str + + @property + def source_event_key(self) -> str: + return self.payload.identity.source_event_key + + @property + def comment_id(self) -> int: + return self.payload.comment_id + + @property + def pr_number(self) -> int: + return self.payload.pr_number + + @property + def actor_login(self) -> str: + return self.payload.actor_login or "" + + @property + def source_created_at(self) -> str: + return self.payload.source_created_at + + @property + def source_freshness_eligible(self) -> bool: + return self.payload.comment_class in {"plain_text", "command_plus_text"} and self.payload.has_non_command_text + + +@dataclass(frozen=True) +class DeferredReviewReplayContext: + payload: DeferredReviewPayload + + @property + def source_event_key(self) -> str: + return self.payload.identity.source_event_key + + @property + def review_id(self) -> int: + return self.payload.review_id + + @property + def pr_number(self) -> int: + return self.payload.pr_number + + @property + def actor_login(self) -> str: + return self.payload.actor_login or "" + + +def _build_deferred_identity(payload: dict) -> DeferredArtifactIdentity: + return DeferredArtifactIdentity( + schema_version=int(payload["schema_version"]), + source_workflow_name=str(payload["source_workflow_name"]), + source_workflow_file=str(payload["source_workflow_file"]), + source_run_id=int(payload["source_run_id"]), + source_run_attempt=int(payload["source_run_attempt"]), + source_event_name=str(payload["source_event_name"]), + source_event_action=str(payload["source_event_action"]), + source_event_key=str(payload["source_event_key"]), + ) + + +def build_deferred_comment_replay_context( + payload: DeferredCommentPayload, + *, + expected_event_name: str, + live_comment_endpoint: str, +) -> DeferredCommentReplayContext: + if payload.identity.source_event_key != f"{expected_event_name}:{payload.comment_id}": + raise RuntimeError("Deferred comment artifact source_event_key mismatch") + return DeferredCommentReplayContext( + payload=payload, + expected_event_name=expected_event_name, + live_comment_endpoint=live_comment_endpoint, + ) + + +def build_deferred_review_replay_context( + payload: DeferredReviewPayload, + *, + expected_event_action: str, +) -> DeferredReviewReplayContext: + expected_prefix = "pull_request_review:" if expected_event_action == "submitted" else "pull_request_review_dismissed:" + if payload.identity.source_event_action != expected_event_action: + raise RuntimeError("Deferred review artifact action mismatch") + if payload.identity.source_event_key != f"{expected_prefix}{payload.review_id}": + raise RuntimeError(f"Deferred review-{expected_event_action} artifact source_event_key mismatch") + return DeferredReviewReplayContext(payload=payload) + + +def _validate_deferred_comment_artifact(payload: dict) -> None: + required = { + "schema_version", + "source_workflow_name", + "source_workflow_file", + "source_run_id", + "source_run_attempt", + "source_event_name", + "source_event_action", + "source_event_key", + "pr_number", + "comment_id", + "comment_class", + "has_non_command_text", + "source_body_digest", + "source_created_at", + } + missing = sorted(required - set(payload)) + if missing: + raise RuntimeError("Deferred comment artifact missing required fields: " + ", ".join(missing)) + if payload.get("schema_version") != 2: + raise RuntimeError("Deferred comment artifact schema_version is not accepted by V18 reconcile") + if not isinstance(payload.get("comment_id"), int) or not isinstance(payload.get("pr_number"), int): + raise RuntimeError("Deferred comment artifact comment_id and pr_number must be integers") + if not isinstance(payload.get("comment_class"), str) or not isinstance(payload.get("has_non_command_text"), bool): + raise RuntimeError("Deferred comment artifact parse fields are malformed") + if not isinstance(payload.get("source_body_digest"), str) or not isinstance(payload.get("source_created_at"), str): + raise RuntimeError("Deferred comment artifact source digest or timestamp is malformed") + + +def _validate_deferred_review_artifact(payload: dict) -> None: + required = { + "schema_version", + "source_workflow_name", + "source_workflow_file", + "source_run_id", + "source_run_attempt", + "source_event_name", + "source_event_action", + "source_event_key", + "pr_number", + "review_id", + } + missing = sorted(required - set(payload)) + if missing: + raise RuntimeError("Deferred review artifact missing required fields: " + ", ".join(missing)) + if payload.get("schema_version") != 2: + raise RuntimeError("Deferred review artifact schema_version is not accepted by V18 reconcile") + if not isinstance(payload.get("review_id"), int) or not isinstance(payload.get("pr_number"), int): + raise RuntimeError("Deferred review artifact review_id and pr_number must be integers") + + +def _validate_deferred_review_comment_artifact(payload: dict) -> None: + required = { + "schema_version", + "source_workflow_name", + "source_workflow_file", + "source_run_id", + "source_run_attempt", + "source_event_name", + "source_event_action", + "source_event_key", + "pr_number", + "comment_id", + "comment_class", + "has_non_command_text", + "source_body_digest", + "source_created_at", + } + missing = sorted(required - set(payload)) + if missing: + raise RuntimeError("Deferred review-comment artifact missing required fields: " + ", ".join(missing)) + if payload.get("schema_version") != 2: + raise RuntimeError("Deferred review-comment artifact schema_version is not accepted by V18 reconcile") + if not isinstance(payload.get("comment_id"), int) or not isinstance(payload.get("pr_number"), int): + raise RuntimeError("Deferred review-comment artifact comment_id and pr_number must be integers") + if not isinstance(payload.get("comment_class"), str) or not isinstance(payload.get("has_non_command_text"), bool): + raise RuntimeError("Deferred review-comment artifact parse fields are malformed") + if not isinstance(payload.get("source_body_digest"), str) or not isinstance(payload.get("source_created_at"), str): + raise RuntimeError("Deferred review-comment artifact source digest or timestamp is malformed") + + +def _validate_observer_noop_payload(payload: dict) -> None: + required = { + "schema_version", + "kind", + "reason", + "source_workflow_name", + "source_workflow_file", + "source_run_id", + "source_run_attempt", + "source_event_name", + "source_event_action", + "source_event_key", + "pr_number", + } + missing = sorted(required - set(payload)) + if missing: + raise RuntimeError("Observer no-op payload missing required fields: " + ", ".join(missing)) + if payload.get("schema_version") != 1: + raise RuntimeError("Observer no-op payload schema_version is not accepted") + if payload.get("kind") != "observer_noop": + raise RuntimeError("Observer no-op payload kind mismatch") + if not isinstance(payload.get("reason"), str) or not payload.get("reason"): + raise RuntimeError("Observer no-op payload reason must be a non-empty string") + if not isinstance(payload.get("pr_number"), int): + raise RuntimeError("Observer no-op payload pr_number must be an integer") + + +def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | DeferredCommentPayload | ObserverNoopPayload: + if not isinstance(payload, dict): + raise RuntimeError("Deferred context payload must be a JSON object") + + if payload.get("kind") == "observer_noop": + _validate_observer_noop_payload(payload) + return ObserverNoopPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + reason=str(payload["reason"]), + raw_payload=payload, + ) + + event_name = payload.get("source_event_name") + event_action = payload.get("source_event_action") + if event_name == "issue_comment" and event_action == "created": + _validate_deferred_comment_artifact(payload) + return DeferredCommentPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + comment_id=int(payload["comment_id"]), + comment_class=str(payload["comment_class"]), + has_non_command_text=bool(payload["has_non_command_text"]), + source_body_digest=str(payload["source_body_digest"]), + source_created_at=str(payload["source_created_at"]), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + if event_name == "pull_request_review_comment" and event_action == "created": + _validate_deferred_review_comment_artifact(payload) + return DeferredCommentPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + comment_id=int(payload["comment_id"]), + comment_class=str(payload["comment_class"]), + has_non_command_text=bool(payload["has_non_command_text"]), + source_body_digest=str(payload["source_body_digest"]), + source_created_at=str(payload["source_created_at"]), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + if event_name == "pull_request_review" and event_action in {"submitted", "dismissed"}: + _validate_deferred_review_artifact(payload) + return DeferredReviewPayload( + identity=_build_deferred_identity(payload), + pr_number=int(payload["pr_number"]), + review_id=int(payload["review_id"]), + source_submitted_at=(str(payload["source_submitted_at"]) if payload.get("source_submitted_at") is not None else None), + source_review_state=(str(payload["source_review_state"]) if payload.get("source_review_state") is not None else None), + source_commit_id=(str(payload["source_commit_id"]) if payload.get("source_commit_id") is not None else None), + actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None), + raw_payload=payload, + ) + raise RuntimeError("Unsupported deferred workflow_run payload") + + +def expected_observer_identity(payload: dict) -> tuple[str, str]: + event_name = payload.get("source_event_name") + event_action = payload.get("source_event_action") + if event_name == "issue_comment" and event_action == "created": + return ( + "Reviewer Bot PR Comment Observer", + ".github/workflows/reviewer-bot-pr-comment-observer.yml", + ) + if event_name == "pull_request_review" and event_action == "submitted": + return ( + "Reviewer Bot PR Review Submitted Observer", + ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml", + ) + if event_name == "pull_request_review" and event_action == "dismissed": + return ( + "Reviewer Bot PR Review Dismissed Observer", + ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml", + ) + if event_name == "pull_request_review_comment" and event_action == "created": + return ( + "Reviewer Bot PR Review Comment Observer", + ".github/workflows/reviewer-bot-pr-review-comment-observer.yml", + ) + raise RuntimeError("Unsupported deferred workflow identity") + + +def artifact_expected_name(payload: dict) -> str: + event_name = payload.get("source_event_name") + event_action = payload.get("source_event_action") + run_id = payload.get("source_run_id") + run_attempt = payload.get("source_run_attempt") + if event_name == "issue_comment" and event_action == "created": + return f"reviewer-bot-comment-context-{run_id}-attempt-{run_attempt}" + if event_name == "pull_request_review" and event_action == "submitted": + return f"reviewer-bot-review-submitted-context-{run_id}-attempt-{run_attempt}" + if event_name == "pull_request_review" and event_action == "dismissed": + return f"reviewer-bot-review-dismissed-context-{run_id}-attempt-{run_attempt}" + if event_name == "pull_request_review_comment" and event_action == "created": + return f"reviewer-bot-review-comment-context-{run_id}-attempt-{run_attempt}" + raise RuntimeError("Unsupported deferred artifact naming") + + +def artifact_expected_payload_name(payload: dict) -> str: + event_name = payload.get("source_event_name") + event_action = payload.get("source_event_action") + if event_name == "issue_comment" and event_action == "created": + return "deferred-comment.json" + if event_name == "pull_request_review" and event_action == "submitted": + return "deferred-review-submitted.json" + if event_name == "pull_request_review" and event_action == "dismissed": + return "deferred-review-dismissed.json" + if event_name == "pull_request_review_comment" and event_action == "created": + return "deferred-review-comment.json" + raise RuntimeError("Unsupported deferred payload path") diff --git a/scripts/reviewer_bot_lib/reconcile_reads.py b/scripts/reviewer_bot_lib/reconcile_reads.py new file mode 100644 index 000000000..91028465a --- /dev/null +++ b/scripts/reviewer_bot_lib/reconcile_reads.py @@ -0,0 +1,110 @@ +"""Live read helpers for deferred reconcile flows.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class ReconcileReadError(RuntimeError): + def __init__(self, message: str, *, failure_kind: str | None = None): + super().__init__(message) + self.failure_kind = failure_kind + + +@dataclass(frozen=True) +class LivePrReplayContext: + issue_author: str + issue_labels: tuple[str, ...] + + +@dataclass(frozen=True) +class LiveCommentReplayContext: + comment_author: str + comment_user_type: str + comment_author_association: str + comment_sender_type: str + comment_installation_id: str + comment_performed_via_github_app: bool + + +def read_reconcile_object(bot, endpoint: str, *, label: str) -> dict: + try: + response = bot.github_api_request("GET", endpoint, retry_policy="idempotent_read") + except SystemExit: + payload = bot.github_api("GET", endpoint) + if not isinstance(payload, dict): + raise ReconcileReadError(f"{label} unavailable", failure_kind="unavailable") + return payload + if not response.ok: + failure_kind = response.failure_kind + if failure_kind == "not_found": + raise ReconcileReadError(f"{label} not found", failure_kind=failure_kind) + raise ReconcileReadError(f"{label} unavailable", failure_kind=failure_kind) + if not isinstance(response.payload, dict): + raise ReconcileReadError(f"{label} payload invalid", failure_kind="invalid_payload") + return response.payload + + +def read_optional_reconcile_object(bot, endpoint: str, *, label: str) -> dict | None: + try: + return read_reconcile_object(bot, endpoint, label=label) + except ReconcileReadError as exc: + if exc.failure_kind == "not_found": + return None + raise + + +def read_reconcile_reviews(bot, issue_number: int) -> list[dict]: + reviews = bot.github.get_pull_request_reviews(issue_number) + if reviews is None: + raise ReconcileReadError(f"live reviews for PR #{issue_number} unavailable", failure_kind="unavailable") + if not isinstance(reviews, list): + raise ReconcileReadError(f"live reviews for PR #{issue_number} payload invalid", failure_kind="invalid_payload") + return reviews + + +def read_live_pr_replay_context(bot, pr_number: int) -> LivePrReplayContext: + pull_request = read_reconcile_object(bot, f"pulls/{pr_number}", label=f"live PR #{pr_number} for reconcile context") + author = pull_request.get("user") + if not isinstance(author, dict): + raise RuntimeError(f"Live PR #{pr_number} is missing author metadata") + author_login = author.get("login") + if not isinstance(author_login, str) or not author_login.strip(): + raise RuntimeError(f"Live PR #{pr_number} is missing a valid author login") + labels = pull_request.get("labels") + if labels is None: + labels = [] + if not isinstance(labels, list): + raise RuntimeError(f"Live PR #{pr_number} labels are malformed") + label_names: list[str] = [] + for label in labels: + if not isinstance(label, dict): + raise RuntimeError(f"Live PR #{pr_number} contains malformed label metadata") + name = label.get("name") + if not isinstance(name, str): + raise RuntimeError(f"Live PR #{pr_number} contains a label without a valid name") + label_names.append(name) + return LivePrReplayContext(issue_author=author_login, issue_labels=tuple(label_names)) + + +def read_live_comment_replay_context(live_comment: dict, payload: dict) -> LiveCommentReplayContext: + user = live_comment.get("user") + if not isinstance(user, dict): + raise RuntimeError("Live deferred comment user metadata is unavailable") + comment_author = user.get("login") or payload.get("actor_login") or "" + if not isinstance(comment_author, str) or not comment_author.strip(): + raise RuntimeError("Live deferred comment author login is unavailable") + comment_user_type = user.get("type") + if not isinstance(comment_user_type, str) or not comment_user_type.strip(): + raise RuntimeError("Live deferred comment user type is unavailable") + author_association = live_comment.get("author_association") + if not isinstance(author_association, str) or not author_association.strip(): + raise RuntimeError("Live deferred comment author association is unavailable") + return LiveCommentReplayContext( + comment_author=comment_author, + comment_user_type=comment_user_type, + comment_author_association=author_association, + comment_sender_type=comment_user_type, + comment_installation_id="", + comment_performed_via_github_app=bool(live_comment.get("performed_via_github_app")), + ) From 0278081774dbe19040729b2c7a68cbcf26dcd2aa Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 04:25:56 +0900 Subject: [PATCH 208/209] refactor(reviewer-bot): implement runbook-driven approval-pending detection --- ...r-bot-review-freshness-operator-runbook.md | 2 +- scripts/reviewer_bot_lib/sweeper.py | 260 ++++++++---------- tests/unit/reviewer_bot/test_sweeper_logic.py | 8 + 3 files changed, 119 insertions(+), 151 deletions(-) diff --git a/docs/reviewer-bot-review-freshness-operator-runbook.md b/docs/reviewer-bot-review-freshness-operator-runbook.md index 4ed7abce4..1d68ad467 100644 --- a/docs/reviewer-bot-review-freshness-operator-runbook.md +++ b/docs/reviewer-bot-review-freshness-operator-runbook.md @@ -74,7 +74,7 @@ Populate this section before production rollout: - environment: - event type: - exact endpoint used: `GET /repos/{owner}/{repo}/actions/runs/{run_id}` -- exact accepted field/value signature: +- exact accepted field/value signature: `{"status": "waiting", "conclusion": null, "name": "approval_pending"}` - negative near-miss examples rejected by tests: - validation run URL or run id: diff --git a/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index 07ee7624d..2447b06e5 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -6,10 +6,12 @@ import json import zipfile from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Any from urllib.parse import quote from . import retrying +from .config import REVIEW_FRESHNESS_RUNBOOK_PATH from .context import SweeperContext from .reconcile import ( _artifact_expected_name, @@ -46,6 +48,23 @@ def _github_repository(bot: SweeperContext) -> str: def _approval_pending_signature_from_runbook() -> dict | None: + runbook_path = Path(REVIEW_FRESHNESS_RUNBOOK_PATH) + if not runbook_path.exists(): + return None + signature_prefix = "- exact accepted field/value signature:" + for line in runbook_path.read_text(encoding="utf-8").splitlines(): + if not line.startswith(signature_prefix): + continue + raw = line.split(":", 1)[1].strip() + if not raw: + return None + if raw.startswith("`") and raw.endswith("`"): + raw = raw[1:-1] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None return None @@ -465,6 +484,82 @@ def _update_observer_watermark(bot, review_data: dict, surface: str, event_time: } +def _complete_surface_scan(bot, review_data: dict, surface: str, discovered: list[dict]) -> None: + if discovered: + last_seen = discovered[-1] + _update_observer_watermark(bot, review_data, surface, last_seen["source_created_at"], last_seen["object_id"]) + return + watermark = _load_surface_watermark(review_data, surface) + watermark["last_scan_started_at"] = watermark.get("last_scan_started_at") or _now_iso() + watermark["last_scan_completed_at"] = _now_iso() + watermark["bootstrap_completed_at"] = watermark.get("bootstrap_completed_at") or _now_iso() + + +def _diagnose_deferred_event( + bot, + review_data: dict, + *, + source_event_key: str, + source_event_name: str, + source_event_action: str, + source_created_at: str, + issue_number: int, + workflow_file: str, + source_event_kind: str, + workflow_runs: list[dict] | None, +) -> None: + existing_gap = review_data.get("deferred_gaps", {}).get(source_event_key, {}) + run_correlation = correlate_candidate_observer_runs( + bot, + source_event_key, + source_event_kind=source_event_kind, + source_event_created_at=source_created_at, + pr_number=issue_number, + workflow_file=workflow_file, + workflow_runs=workflow_runs, + ) + run_correlation["later_recheck_complete"] = bool(existing_gap.get("full_scan_complete")) + artifact_correlation = None + run_detail = None + if run_correlation.get("status") == "candidate_runs_found": + artifact_correlation = inspect_run_artifact_payloads( + bot, + run_correlation.get("candidate_runs", []), + source_event_key, + pr_number=issue_number, + source_event_kind=source_event_kind, + ) + exact_run_id = artifact_correlation.get("correlated_run") if isinstance(artifact_correlation, dict) else None + if isinstance(exact_run_id, int): + run_correlation["correlated_run"] = exact_run_id + run_correlation["correlated_run_found"] = True + run_detail = _maybe_fetch_single_candidate_run_detail(bot, run_correlation, artifact_correlation) + reason, diagnostic_reason = evaluate_deferred_gap_state( + { + **existing_gap, + "source_event_created_at": source_created_at, + }, + run_correlation, + run_detail, + artifact_correlation, + ) + _record_gap_diagnostics( + bot, + review_data, + source_event_key, + source_event_name=source_event_name, + source_event_action=source_event_action, + issue_number=issue_number, + source_created_at=source_created_at, + workflow_file=workflow_file, + run_correlation=run_correlation, + run_detail=run_detail, + artifact_correlation=artifact_correlation, + reason=reason, + diagnostic_reason=diagnostic_reason, + ) + + def _load_surface_watermark(review_data: dict, surface: str) -> dict: watermarks = review_data.setdefault("observer_discovery_watermarks", {}) current = watermarks.get(surface) @@ -837,67 +932,22 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: created_at = discovered["source_created_at"] if _should_skip_discovered_key(bot, review_data, source_event_key, ("reviewer_comment", "contributor_comment")): continue - existing_gap = review_data.get("deferred_gaps", {}).get(source_event_key, {}) workflow_file = ".github/workflows/reviewer-bot-pr-comment-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "issue_comment") - run_correlation = correlate_candidate_observer_runs( - bot, - source_event_key, - source_event_kind="issue_comment:created", - source_event_created_at=created_at, - pr_number=issue_number, - workflow_file=workflow_file, - workflow_runs=workflow_runs, - ) - run_correlation["later_recheck_complete"] = bool(existing_gap.get("full_scan_complete")) - artifact_correlation = None - run_detail = None - if run_correlation.get("status") == "candidate_runs_found": - artifact_correlation = inspect_run_artifact_payloads( - bot, - run_correlation.get("candidate_runs", []), - source_event_key, - pr_number=issue_number, - source_event_kind="issue_comment:created", - ) - exact_run_id = artifact_correlation.get("correlated_run") if isinstance(artifact_correlation, dict) else None - if isinstance(exact_run_id, int): - run_correlation["correlated_run"] = exact_run_id - run_correlation["correlated_run_found"] = True - run_detail = _maybe_fetch_single_candidate_run_detail(bot, run_correlation, artifact_correlation) - reason, diagnostic_reason = evaluate_deferred_gap_state( - { - **existing_gap, - "source_event_created_at": created_at, - }, - run_correlation, - run_detail, - artifact_correlation, - ) - _record_gap_diagnostics( + _diagnose_deferred_event( bot, review_data, - source_event_key, + source_event_key=source_event_key, source_event_name="issue_comment", source_event_action="created", issue_number=issue_number, source_created_at=created_at, workflow_file=workflow_file, - run_correlation=run_correlation, - run_detail=run_detail, - artifact_correlation=artifact_correlation, - reason=reason, - diagnostic_reason=diagnostic_reason, + source_event_kind="issue_comment:created", + workflow_runs=workflow_runs, ) changed = True - if discovered_comments: - last_comment = discovered_comments[-1] - _update_observer_watermark(bot, review_data, "comments", last_comment["source_created_at"], last_comment["object_id"]) - else: - watermark = _load_surface_watermark(review_data, "comments") - watermark["last_scan_started_at"] = watermark.get("last_scan_started_at") or _now_iso() - watermark["last_scan_completed_at"] = _now_iso() - watermark["bootstrap_completed_at"] = watermark.get("bootstrap_completed_at") or _now_iso() + _complete_surface_scan(bot, review_data, "comments", discovered_comments) discovered_reviews, reviews_complete = _discover_visible_review_events(bot, issue_number, review_data) if reviews_complete and isinstance(discovered_reviews, list): for discovered in discovered_reviews: @@ -982,67 +1032,22 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: created_at = discovered["source_created_at"] if _should_skip_discovered_key(bot, review_data, source_event_key, ("reviewer_comment", "contributor_comment")): continue - existing_gap = review_data.get("deferred_gaps", {}).get(source_event_key, {}) workflow_file = ".github/workflows/reviewer-bot-pr-review-comment-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review_comment") - run_correlation = correlate_candidate_observer_runs( - bot, - source_event_key, - source_event_kind="pull_request_review_comment:created", - source_event_created_at=created_at, - pr_number=issue_number, - workflow_file=workflow_file, - workflow_runs=workflow_runs, - ) - run_correlation["later_recheck_complete"] = bool(existing_gap.get("full_scan_complete")) - artifact_correlation = None - run_detail = None - if run_correlation.get("status") == "candidate_runs_found": - artifact_correlation = inspect_run_artifact_payloads( - bot, - run_correlation.get("candidate_runs", []), - source_event_key, - pr_number=issue_number, - source_event_kind="pull_request_review_comment:created", - ) - exact_run_id = artifact_correlation.get("correlated_run") if isinstance(artifact_correlation, dict) else None - if isinstance(exact_run_id, int): - run_correlation["correlated_run"] = exact_run_id - run_correlation["correlated_run_found"] = True - run_detail = _maybe_fetch_single_candidate_run_detail(bot, run_correlation, artifact_correlation) - reason, diagnostic_reason = evaluate_deferred_gap_state( - { - **existing_gap, - "source_event_created_at": created_at, - }, - run_correlation, - run_detail, - artifact_correlation, - ) - _record_gap_diagnostics( + _diagnose_deferred_event( bot, review_data, - source_event_key, + source_event_key=source_event_key, source_event_name="pull_request_review_comment", source_event_action="created", issue_number=issue_number, source_created_at=created_at, workflow_file=workflow_file, - run_correlation=run_correlation, - run_detail=run_detail, - artifact_correlation=artifact_correlation, - reason=reason, - diagnostic_reason=diagnostic_reason, + source_event_kind="pull_request_review_comment:created", + workflow_runs=workflow_runs, ) changed = True - if discovered_review_comments: - last_comment = discovered_review_comments[-1] - _update_observer_watermark(bot, review_data, "review_comments", last_comment["source_created_at"], last_comment["object_id"]) - else: - watermark = _load_surface_watermark(review_data, "review_comments") - watermark["last_scan_started_at"] = watermark.get("last_scan_started_at") or _now_iso() - watermark["last_scan_completed_at"] = _now_iso() - watermark["bootstrap_completed_at"] = watermark.get("bootstrap_completed_at") or _now_iso() + _complete_surface_scan(bot, review_data, "review_comments", discovered_review_comments) discovered_dismissals, dismissals_complete = _discover_visible_review_dismissal_events(bot, issue_number, review_data) if dismissals_complete and isinstance(discovered_dismissals, list): for discovered in discovered_dismissals: @@ -1050,65 +1055,20 @@ def sweep_deferred_gaps(bot, state: dict) -> bool: dismissed_at = discovered["source_created_at"] if _should_skip_discovered_key(bot, review_data, source_event_key, ("review_dismissal",)): continue - existing_gap = review_data.get("deferred_gaps", {}).get(source_event_key, {}) workflow_file = ".github/workflows/reviewer-bot-pr-review-dismissed-observer.yml" workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review") - run_correlation = correlate_candidate_observer_runs( - bot, - source_event_key, - source_event_kind="pull_request_review:dismissed", - source_event_created_at=dismissed_at, - pr_number=issue_number, - workflow_file=workflow_file, - workflow_runs=workflow_runs, - ) - run_correlation["later_recheck_complete"] = bool(existing_gap.get("full_scan_complete")) - artifact_correlation = None - run_detail = None - if run_correlation.get("status") == "candidate_runs_found": - artifact_correlation = inspect_run_artifact_payloads( - bot, - run_correlation.get("candidate_runs", []), - source_event_key, - pr_number=issue_number, - source_event_kind="pull_request_review:dismissed", - ) - exact_run_id = artifact_correlation.get("correlated_run") if isinstance(artifact_correlation, dict) else None - if isinstance(exact_run_id, int): - run_correlation["correlated_run"] = exact_run_id - run_correlation["correlated_run_found"] = True - run_detail = _fetch_run_detail(bot, exact_run_id) - reason, diagnostic_reason = evaluate_deferred_gap_state( - { - **existing_gap, - "source_event_created_at": dismissed_at, - }, - run_correlation, - run_detail, - artifact_correlation, - ) - _record_gap_diagnostics( + _diagnose_deferred_event( bot, review_data, - source_event_key, + source_event_key=source_event_key, source_event_name="pull_request_review", source_event_action="dismissed", issue_number=issue_number, source_created_at=dismissed_at, workflow_file=workflow_file, - run_correlation=run_correlation, - run_detail=run_detail, - artifact_correlation=artifact_correlation, - reason=reason, - diagnostic_reason=diagnostic_reason, + source_event_kind="pull_request_review:dismissed", + workflow_runs=workflow_runs, ) changed = True - if discovered_dismissals: - last_dismissal = discovered_dismissals[-1] - _update_observer_watermark(bot, review_data, "reviews_dismissed", last_dismissal["source_created_at"], last_dismissal["object_id"]) - else: - watermark = _load_surface_watermark(review_data, "reviews_dismissed") - watermark["last_scan_started_at"] = watermark.get("last_scan_started_at") or _now_iso() - watermark["last_scan_completed_at"] = _now_iso() - watermark["bootstrap_completed_at"] = watermark.get("bootstrap_completed_at") or _now_iso() + _complete_surface_scan(bot, review_data, "reviews_dismissed", discovered_dismissals) return changed diff --git a/tests/unit/reviewer_bot/test_sweeper_logic.py b/tests/unit/reviewer_bot/test_sweeper_logic.py index 8ad0de591..dd21b00b6 100644 --- a/tests/unit/reviewer_bot/test_sweeper_logic.py +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -298,6 +298,14 @@ def test_observer_run_reason_mapping_and_near_miss_signature(): assert sweeper.observer_run_reason_from_details({"status": "waiting", "conclusion": None, "name": "almost"}, signature) == "observer_state_unknown" +def test_approval_pending_signature_is_loaded_from_runbook(): + assert sweeper._approval_pending_signature_from_runbook() == { + "status": "waiting", + "conclusion": None, + "name": "approval_pending", + } + + def test_negative_missing_run_requires_full_scan_and_recheck(): gap = { "source_event_created_at": "2026-03-15T00:00:00Z", From ef9698641dbfeb04aeb7456729340a7099cc1b50 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Mon, 6 Apr 2026 04:29:21 +0900 Subject: [PATCH 209/209] refactor(reviewer-bot): restore reconcile payload exports --- scripts/reviewer_bot_lib/reconcile.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/reviewer_bot_lib/reconcile.py b/scripts/reviewer_bot_lib/reconcile.py index 3c09b1910..65fb29b91 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -13,10 +13,14 @@ from .comment_routing import classify_comment_payload, classify_issue_comment_actor from .context import CommentEventRequest from .reconcile_payloads import ( + DeferredArtifactIdentity, DeferredCommentPayload, DeferredCommentReplayContext, DeferredReviewPayload, + DeferredReviewReplayContext, ObserverNoopPayload, + artifact_expected_name as _artifact_expected_name, + artifact_expected_payload_name as _artifact_expected_payload_name, build_deferred_comment_replay_context, build_deferred_review_replay_context, parse_deferred_context_payload,