diff --git a/.github/reviewer-bot-tests/conftest.py b/.github/reviewer-bot-tests/conftest.py deleted file mode 100644 index fdd82486b..000000000 --- a/.github/reviewer-bot-tests/conftest.py +++ /dev/null @@ -1,115 +0,0 @@ -import pytest - -from scripts import reviewer_bot - - -@pytest.fixture(autouse=True) -def clear_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()) - yield - - -@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/test_main.py b/.github/reviewer-bot-tests/test_main.py deleted file mode 100644 index 65eb43232..000000000 --- a/.github/reviewer-bot-tests/test_main.py +++ /dev/null @@ -1,1014 +0,0 @@ -import pytest - -from scripts import reviewer_bot -from scripts.reviewer_bot_lib.context import ( - GitHubTransportContext, - LeaseLockContext, - ReviewerBotContext, - StateStoreContext, -) - - -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", "") - 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 _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") - 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 - - -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")), - ) - - 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() - - 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_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.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, "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) - monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True) - 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 == [] - - -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: 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, - "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"}, - } - ], - ) - monkeypatch.setattr(reviewer_bot, "save_state", lambda current: 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] - - -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_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", "") - 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 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 28accb930..000000000 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ /dev/null @@ -1,2759 +0,0 @@ -import json -import os -import subprocess -from datetime import timedelta -from pathlib import Path - -import pytest -import yaml - -from builder import build_cli -from scripts import reviewer_bot -from scripts.reviewer_bot_lib import comment_routing, sweeper - - -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_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" - - -@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 False - assert "42" not in 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_and_syncs_status(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) - synced = [] - monkeypatch.setattr( - reviewer_bot, - "sync_status_labels_for_items", - lambda state_obj, issue_numbers: synced.append(list(issue_numbers)) or True, - ) - 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 synced == [[42]] - 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.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, "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: False) - 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, - "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"}, - } - ], - ) - 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_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_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, - ) - 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_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_api(method, endpoint, data=None): - if endpoint == "pulls/42": - return {"head": {"sha": "head-1"}} - return None - - 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) - 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_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_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 False - 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_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_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_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_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/workflows/reviewer-bot-tests.yml b/.github/workflows/reviewer-bot-tests.yml index eac04986f..fc00adb04 100644 --- a/.github/workflows/reviewer-bot-tests.yml +++ b/.github/workflows/reviewer-bot-tests.yml @@ -4,21 +4,23 @@ on: push: paths: - 'scripts/reviewer_bot.py' + - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' + - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - - '.github/reviewer-bot-tests/**' - 'pyproject.toml' pull_request: paths: - 'scripts/reviewer_bot.py' + - 'scripts/reviewer_bot_lib/**' - 'scripts/*.py' + - 'tests/**' - '.github/workflows/reviewer-bot-*.yml' - - '.github/reviewer-bot-tests/**' - 'pyproject.toml' workflow_dispatch: jobs: - reviewer-bot-tests: + reviewer-bot-unit: runs-on: ubuntu-latest steps: - name: Checkout repository @@ -30,5 +32,82 @@ 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 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 + uv run pytest tests/unit/reviewer_bot tests/integration/reviewer_bot + --cov=scripts.reviewer_bot + --cov=scripts.reviewer_bot_lib + --cov-branch + --cov-report=term-missing + --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("artifacts/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 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/ 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/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", diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 61a54c3b5..343c66ea3 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -1,902 +1,57 @@ #!/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 random import sys -from collections.abc import Iterable -from datetime import datetime -from datetime import timezone as _timezone -from pathlib import Path -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_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.reviews as reviews_module -import scripts.reviewer_bot_lib.state_store as state_store_module -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, +import time + +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 +from scripts.reviewer_bot_lib.bootstrap_runtime import ( + build_runtime as build_bootstrap_runtime, ) -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, -) - -requests = github_api_module.requests -random = lease_lock_module.random -time = lease_lock_module.time - -# ============================================================================== -# GitHub API Helpers -# ============================================================================== +from scripts.reviewer_bot_lib.context import EventContext, ExecutionResult +from scripts.reviewer_bot_lib.runtime import ReviewerBotRuntime -ACTIVE_LEASE_CONTEXT: LeaseContext | None = None -TOUCHED_ISSUE_NUMBERS: set[int] = set() +def _runtime_bot(runtime: ReviewerBotRuntime | None = None) -> ReviewerBotRuntime: + if runtime is not None: + return runtime + return build_runtime() -def _runtime_bot() -> Any: - return sys.modules[__name__] +def build_event_context(runtime: ReviewerBotRuntime | None = None) -> EventContext: + return build_app_event_context(_runtime_bot(runtime)) -def get_github_token() -> str: - return github_api_module.get_github_token() +def execute_run(context: EventContext, runtime: ReviewerBotRuntime | None = None) -> ExecutionResult: + return execute_app_run(_runtime_bot(runtime), context) -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, - *, - suppress_error_log: bool = False, -) -> GitHubApiResult: - return github_api_module.github_api_request( - _runtime_bot(), - method, - endpoint, - data, - extra_headers, - 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, - suppress_error_log: bool = False, -) -> GitHubApiResult: - return github_api_module.github_graphql_request( - _runtime_bot(), - query, - variables, - token=token, - suppress_error_log=suppress_error_log, - ) +def build_runtime() -> ReviewerBotRuntime: + return _build_runtime() -def github_graphql( - query: str, - variables: dict | None = None, +def main( + runtime: ReviewerBotRuntime | None = None, *, - token: str | None = None, -) -> Any | None: - return github_api_module.github_graphql(_runtime_bot(), query, variables, token=token) + runtime_factory=build_runtime, +) -> None: + resolved_runtime = runtime or runtime_factory() + run_app_main(_runtime_bot(resolved_runtime)) -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 _build_runtime() -> ReviewerBotRuntime: + return build_bootstrap_runtime( + requests=github_api.requests, + sys=sys, + random=random, + time=time, + active_lease_context=None, ) -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) - - -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 - - -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]: - 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 check_user_permission(username: str, required_permission: str = "triage") -> bool: - 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: - return lease_lock_module.renew_state_issue_lease_lock(_runtime_bot(), context) - - -def acquire_state_issue_lease_lock() -> LeaseContext: - return lease_lock_module.acquire_state_issue_lease_lock(_runtime_bot()) - - -def release_state_issue_lease_lock() -> bool: - return lease_lock_module.release_state_issue_lease_lock(_runtime_bot()) - - -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) - - -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) - - -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) - - -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 resolve_workflow_run_pr_number() -> int: - return commands_module.resolve_workflow_run_pr_number(_runtime_bot()) - - -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) - - -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) - - -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) - - -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) - - -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) - - -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) - - -# ============================================================================== -# Event Handlers -# ============================================================================== - - -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) - - -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) - - -def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool: - return reviews_module.update_reviewer_activity(state, issue_number, reviewer) - - -def mark_review_complete( - state: dict, - issue_number: int, - reviewer: str | None, - source: str, -) -> bool: - return reviews_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) - - -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() - ) - - has_triage = False - if not is_current_reviewer: - has_triage = check_user_permission(comment_author, "triage") - - if not is_current_reviewer and not has_triage: - 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) -> bool: - 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) - - -def build_pr_comment_observer_payload(issue_number: int) -> dict: - return comment_routing_module.build_pr_comment_observer_payload(_runtime_bot(), issue_number) - - -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 main(): - app_module.main(_runtime_bot()) - - if __name__ == "__main__": main() diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index d93331d4b..9f2d91458 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -1,19 +1,28 @@ """Top-level reviewer-bot orchestration.""" -import os import sys -from .context import ReviewerBotContext +from .context import ( + AppEventContextRuntime, + AppExecutionRuntime, + 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, ) -def _revalidate_epoch(bot: ReviewerBotContext, expected_epoch: str | None, phase: str) -> None: +def _log(bot: AppExecutionRuntime, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + +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( @@ -21,7 +30,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): @@ -30,17 +39,31 @@ 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 -def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action: str) -> str: - """Classify whether a run can mutate reviewer-bot state.""" +def build_event_context(bot: AppEventContextRuntime) -> EventContext: + return decode_event_context(bot) + + +def _classify_event_intent_from_context(bot: AppEventContextRuntime, 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 @@ -48,8 +71,8 @@ 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": - trust_class = os.environ.get("REVIEWER_BOT_TRUST_CLASS", "").strip() + if context.is_pull_request is True: + 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 @@ -68,14 +91,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 @@ -85,22 +106,57 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action return bot.EVENT_INTENT_NON_MUTATING_READONLY -def event_requires_lease_lock(bot: ReviewerBotContext, event_name: str, event_action: str) -> bool: +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( + 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: 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 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: AppExecutionRuntime, 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}, " - 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 @@ -119,66 +175,68 @@ def main(bot: ReviewerBotContext): 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) 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.workflow.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) + state, sync_changes = bot.adapters.workflow.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": - 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 os.environ.get("WORKFLOW_RUN_EVENT", "").strip() in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: - state_changed = bot.handle_workflow_run_event(state) + if context.workflow_run_event in {"pull_request_review", "issue_comment", "pull_request_review_comment"}: + state_changed = bot.handlers.handle_workflow_run_event(state) else: - print( - "Ignoring workflow_run event with unsupported source event: " - f"{os.environ.get('WORKFLOW_RUN_EVENT', '').strip() 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() @@ -204,7 +262,7 @@ def main(bot: ReviewerBotContext): 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 @@ -217,16 +275,16 @@ def main(bot: ReviewerBotContext): "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): + 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: @@ -236,16 +294,18 @@ def main(bot: ReviewerBotContext): ) _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.workflow.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") - 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." ) @@ -253,40 +313,48 @@ def main(bot: ReviewerBotContext): 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." ) - 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" - ) + execution_state_changed = bool(state_changed or sync_changes or restored or status_labels_changed) + + bot.write_output( + "state_changed", + "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: - if not bot.release_state_issue_lease_lock(): + if not bot.locks.release(): 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 - 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: AppExecutionRuntime): + """Main entry point for the reviewer bot.""" + result = execute_run(bot, build_event_context(bot)) + if result.exit_code: + sys.exit(result.exit_code) diff --git a/scripts/reviewer_bot_lib/automation.py b/scripts/reviewer_bot_lib/automation.py index 95ba4872a..6314ae1cd 100644 --- a/scripts/reviewer_bot_lib/automation.py +++ b/scripts/reviewer_bot_lib/automation.py @@ -1,10 +1,20 @@ """Automation-heavy reviewer-bot helpers.""" -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 as decode_target_repo_root, +) +from .event_inputs import ( + parse_issue_labels as decode_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) @@ -32,13 +42,26 @@ def list_changed_files(repo_root: Path) -> list[str]: 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) +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(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(bot) -> list[str]: + return decode_issue_labels(bot) + + def get_default_branch(bot) -> str: repo_info = bot.github_api("GET", "") if isinstance(repo_info, dict): @@ -46,23 +69,41 @@ def get_default_branch(bot) -> str: return "main" -def find_open_pr_for_branch(bot, branch: str) -> dict | None: - owner = os.environ.get("REPO_OWNER", "").strip() +def find_open_pr_for_branch_status(bot, branch: str) -> tuple[str, dict | None]: + owner = bot.get_config_value("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 = 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" @@ -78,20 +119,34 @@ 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( + bot, + 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 - if not bot.check_user_permission(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": return "❌ You must have triage permissions to run this command.", False - repo_root = get_target_repo_root() - if bot.list_changed_files(repo_root): + repo_root = Path(privileged_request.target_repo_root) if privileged_request.target_repo_root else get_target_repo_root(bot) + 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, @@ -103,21 +158,21 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: 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 @@ -131,16 +186,16 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: ) 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", @@ -153,11 +208,11 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author: ], 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/bootstrap_runtime.py b/scripts/reviewer_bot_lib/bootstrap_runtime.py new file mode 100644 index 000000000..62694fb14 --- /dev/null +++ b/scripts/reviewer_bot_lib/bootstrap_runtime.py @@ -0,0 +1,436 @@ +"""Bootstrap runtime composition for the reviewer-bot entrypoint.""" + +from __future__ import annotations + +from . import ( + automation, + commands, + comment_routing, + config, + events, + github_api, + lease_lock, + lifecycle, + maintenance, + members, + 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, + _EnvConfig, + _FileOutputSink, + _JsonDeferredPayloadLoader, +) + + +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) + + 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): + 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()) + + +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) + + +class _BootstrapReviewStateAdapterServices: + 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) + + 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) + + 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 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) + + +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()) + + +class _BootstrapAutomationAdapterServices: + def __init__(self, runtime_getter): + self._runtime_getter = runtime_getter + + def _runtime(self): + return self._runtime_getter() + + 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()) + + +class _BootstrapStateLockAdapterServices: + def __init__(self, runtime_getter, lock_services): + self._runtime_getter = runtime_getter + self._lock_services = lock_services + + 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()) + + 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 self._lock_services.refresh() + + def acquire_state_issue_lease_lock(self): + return self._lock_services.acquire() + + def release_state_issue_lease_lock(self): + return self._lock_services.release() + + def get_active_lease_context(self): + return self._runtime().ACTIVE_LEASE_CONTEXT + + +class _BootstrapAdapterGroups: + def __init__(self, *, github, review_state, commands, queue, workflow, automation, state_lock): + self.github = github + self.review_state = review_state + self.commands = commands + self.queue = queue + 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 + + 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 = _BootstrapStateStoreServices(runtime_getter) + github_services = _BootstrapGitHubServices(runtime_getter) + lock_services = _BootstrapLockServices(runtime_getter) + handlers = _BootstrapHandlerServices(runtime_getter) + adapters = _BootstrapAdapterGroups( + github=github_services, + 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), + ) + + runtime = ReviewerBotRuntime( + requests=requests, + 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, + handlers=handlers, + adapters=adapters, + active_lease_context=active_lease_context, + ) + return runtime diff --git a/scripts/reviewer_bot_lib/commands.py b/scripts/reviewer_bot_lib/commands.py index 4dd9bfdec..bd066e018 100644 --- a/scripts/reviewer_bot_lib/commands.py +++ b/scripts/reviewer_bot_lib/commands.py @@ -1,16 +1,60 @@ """Reviewer-bot command parsing and handlers.""" -import json -import os import re -import subprocess from datetime import datetime, timezone -from pathlib import Path +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, +) 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 _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + +_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) + + +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 strip_code_blocks(comment_body: str) -> str: sanitized = comment_body @@ -41,8 +85,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] @@ -84,39 +127,55 @@ 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" - 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") - failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) + 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.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.post_comment(issue_number, failure_comment) + bot.github.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.github.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) + bot.github.post_comment(issue_number, guidance) return assignment_attempt, failure_comment -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) +def _current_assignees_or_error(bot, issue_number: int) -> tuple[list[str] | None, str | None]: + 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 + + +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(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 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 @@ -125,22 +184,20 @@ 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) - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + if assignment_request.issue_author: + skip_set.add(assignment_request.issue_author) + 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.reposition_member_as_next(state, passed_reviewer) - bot.unassign_reviewer(issue_number, 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, 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: @@ -156,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(bot, issue_number=issue_number) try: parsed_date = datetime.strptime(return_date, "%Y-%m-%d").date() except ValueError: @@ -195,22 +261,22 @@ 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: - bot.unassign_reviewer(issue_number, comment_author) - issue_author = os.environ.get("ISSUE_AUTHOR", "") - skip_set = {issue_author} if issue_author else set() - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + 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.queue.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." @@ -228,12 +294,19 @@ 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(bot, issue_number=issue_number) pattern = r'(?:(?<=^)|(?<=\s))([+-])(.+?)(?=\s[+-]|\s*$)' 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 @@ -245,21 +318,20 @@ def handle_label_command(bot, state: dict, issue_number: int, label_string: str) 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 os.environ.get("IS_PULL_REQUEST", "false").lower() != "true": - review_data = bot.ensure_review_entry(state, issue_number) + if label == "sign-off: create pr" and not assignment_request.is_pull_request: + 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" ) - 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 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}`") @@ -269,48 +341,8 @@ def handle_label_command(bot, state: dict, issue_number: int, label_string: str) return "\n".join(results), all_success, state_changed -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] - - -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] +def parse_issue_labels(bot) -> list[str]: + return bot_parse_issue_labels(bot) def get_default_branch(bot) -> str: @@ -321,39 +353,34 @@ 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: - 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(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") - 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: 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): @@ -364,83 +391,27 @@ def resolve_workflow_run_pr_number(bot) -> int: 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 -def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None: - existing = bot.find_open_pr_for_branch(branch) - if existing: - return existing - 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) -> tuple[str, bool]: - if os.environ.get("IS_PULL_REQUEST", "false").lower() == "true": - return "❌ This command can only be used on issues, not PRs.", False - 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"): - 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) + 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 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(bot, 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})" @@ -467,24 +438,31 @@ 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(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: 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", "") + bot.github.unassign_reviewer(issue_number, assignee) 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}." @@ -494,8 +472,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(bot, issue_number=issue_number) target_username = None reason = None releasing_other = False @@ -503,7 +489,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.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": 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 +505,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: @@ -531,18 +522,25 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author: 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": - bot.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 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(bot, issue_number=issue_number) username = username.lstrip("@") if not username: return (f"❌ Missing username. Usage: `{bot.BOT_MENTION} /r? @username`"), False @@ -555,17 +553,17 @@ 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", "") + bot.github.unassign_reviewer(issue_number, assignee) 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: @@ -576,22 +574,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]: - current_assignees = bot.get_issue_assignees(issue_number) +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(bot, 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() - next_reviewer = bot.get_next_reviewer(state, skip_usernames=skip_set) + bot.github.unassign_reviewer(issue_number, assignee) + skip_set = {assignment_request.issue_author} if assignment_request.issue_author else 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( 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_application.py b/scripts/reviewer_bot_lib/comment_application.py new file mode 100644 index 000000000..578dd83c0 --- /dev/null +++ b/scripts/reviewer_bot_lib/comment_application.py @@ -0,0 +1,290 @@ +"""Comment mutation and replay application helpers.""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone + +from . import commands as commands_module +from . import config as config_module +from .context import AssignmentRequest, CommentEventRequest +from .review_state import ( + accept_channel_event, + ensure_review_entry, + record_reviewer_activity, +) + + +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 = 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 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 = 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") + 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 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, +) -> tuple[bool, dict]: + if request.is_pull_request: + return False, {"reason": "pull_request_target_not_allowed"} + 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.github.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 = 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.github.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.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.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 + state_changed = False + assignment_request = build_assignment_request_from_comment(request) + if command == "pass": + response, success = commands_module.handle_pass_command( + bot, + state, + issue_number, + comment_author, + " ".join(args) if args else None, + request=assignment_request, + ) + state_changed = success + elif command == "away": + if args: + response, success = commands_module.handle_pass_until_command( + bot, + 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 = commands_module.handle_label_command( + bot, + state, + issue_number, + " ".join(args), + request=assignment_request, + ) + elif command == "sync-members": + response, success = commands_module.handle_sync_members_command(bot, state) + state_changed = success + elif command == "queue": + response, success = commands_module.handle_queue_command(bot, state) + elif command == "commands": + response, success = commands_module.handle_commands_command(bot) + elif command == "claim": + response, success = commands_module.handle_claim_command( + bot, + state, + issue_number, + comment_author, + request=assignment_request, + ) + state_changed = success + elif command == "release": + response, success = commands_module.handle_release_command( + bot, + state, + issue_number, + comment_author, + list(args), + request=assignment_request, + ) + 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( + bot, + state, + issue_number, + args[0] if args else "", + request=assignment_request, + ) + state_changed = success + elif command == "assign-from-queue": + response, success = commands_module.handle_assign_from_queue_command( + bot, + 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" + 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{config_module.get_commands_help()}" + comment_id = request.comment_id + if comment_id > 0 and command != "_multiple_commands": + bot.github.add_reaction(comment_id, "eyes") + if success: + bot.github.add_reaction(comment_id, "+1") + if response: + bot.github.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 505593581..7bfdac96e 100644 --- a/scripts/reviewer_bot_lib/comment_routing.py +++ b/scripts/reviewer_bot_lib/comment_routing.py @@ -3,10 +3,26 @@ from __future__ import annotations import hashlib -import os 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, +) +from .event_inputs import ( + build_pr_comment_trust_context as decode_pr_comment_trust_context, +) + + +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + def _now() -> datetime: return datetime.now(timezone.utc) @@ -20,26 +36,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 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(bot) -> PrCommentTrustContext: + return decode_pr_comment_trust_context(bot) -def _issue_state() -> str: - return os.environ.get("ISSUE_STATE", "").strip().lower() +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 _require_v18_for_pr(state: dict, context: str) -> bool: - if not _is_pr_event(): +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(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 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: @@ -47,7 +77,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: @@ -59,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.commands.strip_code_blocks(body)) if not normalized: return { "comment_class": "empty_or_whitespace", @@ -72,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.commands.parse_command(normalized) command = None args: list[str] = [] if parsed: @@ -93,21 +123,27 @@ 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: + 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: return author.strip().lower() == bot.BOT_NAME.lower() or author.strip().lower() == bot.BOT_MENTION.lstrip("@").lower() @@ -121,19 +157,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 +182,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 = _resolve_comment_request(bot, request, issue_number=issue_number) + return _classify_pr_comment_processing_target( + bot, + comment_request, + _resolve_trust_context(bot, 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 = _resolve_comment_request(bot, request, issue_number=issue_number) + return _route_issue_comment_trust( + bot, + comment_request, + _resolve_trust_context(bot, 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,212 +259,83 @@ 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"] - 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" + body = request.comment_body + 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": 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']}", - } - - -def _record_conversation_freshness(bot, state: dict, issue_number: int, comment_author: str, comment_id: int, created_at: str) -> bool: - 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(): - 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, - ) - bot.reviews_module.record_reviewer_activity(review_data, created_at) - return 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, issue_number: int, comment_author: str) -> tuple[bool, dict]: - if _is_pr_event(): - 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"} - if not bot.check_user_permission(comment_author, "triage"): - return False, {"reason": "authorization_failed"} - return True, { - "command_name": "accept-no-fls-changes", - "issue_number": issue_number, - "actor": comment_author, - "authorization": {"required_permission": "triage", "authorized": True}, - "target": {"kind": "issue", "number": issue_number, "labels": sorted(labels)}, + "source_artifact_name": ( + f"reviewer-bot-comment-context-{trust_context.github_run_id}-attempt-" + f"{trust_context.github_run_attempt}" + ), } -def _handle_command(bot, state: dict, issue_number: int, comment_author: str, classified: dict) -> bool: - command = classified.get("command") - args = classified.get("args") or [] - if not isinstance(command, str): - return False - actor_class = classify_issue_comment_actor() - 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', '')}" - if command == "accept-no-fls-changes": - is_valid, metadata = _validate_accept_no_fls_changes_handoff(bot, issue_number, comment_author) - 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 = int(os.environ.get("COMMENT_ID", "0") or 0) - 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, 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) - 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 - 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 - return state_changed - - -def handle_comment_event(bot, state: dict) -> bool: +def build_pr_comment_observer_payload( + bot, + issue_number: int, + request: CommentEventRequest | None = None, + trust_context: PrCommentTrustContext | None = None, +) -> dict: + comment_request = _resolve_comment_request(bot, request, issue_number=issue_number) + return _build_pr_comment_observer_payload( + bot, + comment_request, + _resolve_trust_context(bot, trust_context), + ) + + +def _process_comment_event(bot, state: dict, request: CommentEventRequest) -> bool: + return process_comment_event( + bot, + state, + request, + classify_comment_payload=classify_comment_payload, + classify_issue_comment_actor=_classify_issue_comment_actor, + ) + + +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 = _resolve_comment_request(bot, 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, + _resolve_trust_context(bot, trust_context), + ) if route == "safe_noop": return False if route == "issue_direct": - if _issue_state() == "closed": - state.get("active_reviews", {}).pop(str(issue_number), None) - print(f"Ignoring direct comment on closed issue #{issue_number}") - return False - return _process_comment_event(bot, state, issue_number) + if comment_request.issue_state == "closed": + removed = state.get("active_reviews", {}).pop(str(issue_number), None) + _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, "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, 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/config.py b/scripts/reviewer_bot_lib/config.py index 428455d84..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" @@ -197,11 +202,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..fa8a0db0a 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -3,24 +3,288 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from datetime import datetime from typing import Any, Protocol, runtime_checkable from .config import AssignmentAttempt, GitHubApiResult, LeaseContext, StateIssueSnapshot +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 + + +@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 + + +@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 = "" + + +@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 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 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): + github: Any + adapters: Any + + +@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 + 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]: ... @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 + 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 +class GitHubTransportContext(GitHubApiContext, Protocol): + """Compatibility transport surface expected by call sites.""" + def github_api_request( self, method: str, @@ -28,6 +292,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 +303,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,23 +315,23 @@ 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: ... @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 - STATE_READ_RETRY_LIMIT: int - STATE_READ_RETRY_BASE_SECONDS: float - 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, @@ -71,11 +339,10 @@ 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: ... - 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, @@ -89,25 +356,38 @@ 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 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: ... @runtime_checkable -class LeaseLockContext(Protocol): - """Lock-specific runtime surface used by lease-lock helpers.""" +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 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] - 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: ... @@ -119,11 +399,12 @@ 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: ... 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, @@ -131,12 +412,69 @@ 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): + 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 + sleeper: Sleeper + 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]: ... + def lock_api_retry_limit(self) -> int: ... + def lock_retry_base_seconds(self) -> float: ... + + @runtime_checkable class ReviewerBotContext(GitHubTransportContext, StateStoreContext, LeaseLockContext, Protocol): """Broader runtime surface expected by orchestration-heavy extracted modules. @@ -151,6 +489,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: ... @@ -169,6 +511,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, @@ -179,3 +524,30 @@ 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, request: AssignmentRequest | None = None): ... + def handle_pass_until_command( + self, + state: dict, + issue_number: int, + 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, 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, 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, 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, + request: PrivilegedCommandRequest | None = None, + ): ... + def get_commands_help(self) -> str: ... diff --git a/scripts/reviewer_bot_lib/event_inputs.py b/scripts/reviewer_bot_lib/event_inputs.py new file mode 100644 index 000000000..0d9c00dbd --- /dev/null +++ b/scripts/reviewer_bot_lib/event_inputs.py @@ -0,0 +1,196 @@ +"""Decode runtime/config inputs into typed reviewer-bot request objects.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .context import ( + AssignmentRequest, + CommentEventRequest, + EventContext, + EventInputsContext, + IssueLifecycleRequest, + LabelEventRequest, + ManualDispatchRequest, + PrCommentTrustContext, + PrivilegedCommandRequest, + PullRequestSyncRequest, +) + + +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(bot: EventInputsContext) -> list[str]: + return list(_parse_labels(bot.get_config_value("ISSUE_LABELS", "[]"))) + + +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: EventInputsContext) -> 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(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"))), + 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(bot: EventInputsContext) -> PrCommentTrustContext: + return PrCommentTrustContext( + 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(bot: EventInputsContext, *, issue_number: int) -> AssignmentRequest: + return AssignmentRequest( + issue_number=issue_number, + 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( + bot: EventInputsContext, + *, + 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=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=_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(), + ) + + +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")), + privileged_source_event_key=bot.get_config_value("PRIVILEGED_SOURCE_EVENT_KEY").strip(), + ) + + +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"))), + 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: 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"))), + label_name=bot.get_config_value("LABEL_NAME"), + ) + + +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(), + event_created_at=bot.get_config_value("EVENT_CREATED_AT").strip(), + ) diff --git a/scripts/reviewer_bot_lib/events.py b/scripts/reviewer_bot_lib/events.py index 80549b198..b954310ae 100644 --- a/scripts/reviewer_bot_lib/events.py +++ b/scripts/reviewer_bot_lib/events.py @@ -2,45 +2,51 @@ from __future__ import annotations -import os +from .context import EventHandlerContext + + +def _log(bot: EventHandlerContext, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) 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: EventHandlerContext) -> 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: EventHandlerContext, state: dict, context: str) -> bool: + if not _is_pr_event(bot): 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 -def _require_legacy_for_legacy_pr(state: dict, context: str) -> bool: - if not _is_pr_event(): +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) 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 -def handle_pull_request_review_event(bot, state: dict) -> bool: - issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + + +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 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 = 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}") + _log(bot, "info", f"Deferring pull_request_review {review_action} for #{issue_number}", issue_number=issue_number, review_action=review_action) return False diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 2a57973e0..c8303e1bd 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -1,34 +1,125 @@ """GitHub transport and issue/PR mutation helpers.""" import json -import os import random -import sys -import time from urllib.parse import quote import requests +from . import retrying from .config import ( LOCK_API_RETRY_LIMIT, LOCK_RETRY_BASE_SECONDS, 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 -def get_github_token() -> str: - token = os.environ.get("GITHUB_TOKEN") + +class _RandomJitter: + def uniform(self, lower: float, upper: float) -> float: + return random.uniform(lower, upper) + + +def _log(bot: GitHubApiContext, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + +def _sleep(bot: GitHubApiContext, seconds: float) -> None: + 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=bot.jitter, + ) + + +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) + + +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 _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 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 + 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(bot: GitHubApiContext) -> 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 -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 = 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 @@ -36,16 +127,19 @@ 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, 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']}" + repo = f"{bot.get_config_value('REPO_OWNER')}/{bot.get_config_value('REPO_NAME')}" url = f"https://api.github.com/repos/{repo}/{endpoint}" headers = { @@ -56,27 +150,85 @@ 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 = retrying.max_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) + for attempt in range(1, max_attempts + 1): try: - payload = response.json() - except ValueError: - payload = None + 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 attempt < max_attempts: + retry_attempts += 1 + _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)) + 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 + invalid_payload = False + if response.content: + try: + payload = response.json() + except ValueError: + invalid_payload = True + + 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( + 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, 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 + _sleep(bot, _retry_delay(bot, LOCK_RETRY_BASE_SECONDS, retry_attempts)) + continue + + if not suppress_error_log: + _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, + 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): @@ -89,48 +241,109 @@ 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, *, 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 = retrying.max_attempts_for_policy(retry_policy, LOCK_API_RETRY_LIMIT) + 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 = bot.graphql_transport.query( + "https://api.github.com/graphql", + headers=headers, + query=query, + variables=variables, + 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: + retry_attempts += 1 + _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)) + 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 _should_retry_status(response.status_code) and attempt < max_attempts: + retry_attempts += 1 + _sleep(bot, _retry_delay(bot, 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) + _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, + 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 +377,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: @@ -181,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 @@ -201,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 @@ -231,16 +454,18 @@ 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 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]} @@ -250,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) @@ -266,12 +491,17 @@ 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})" + delay = _retry_delay(bot, lock_retry_base_seconds, attempt) + _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) + _sleep(bot, delay) continue return bot.AssignmentAttempt( success=False, @@ -279,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) @@ -290,11 +523,11 @@ 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: - 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) @@ -312,15 +545,27 @@ def get_assignment_failure_comment(bot: GitHubTransportContext, reviewer: str, a return None -def get_issue_assignees(bot: GitHubTransportContext, issue_number: int) -> list[str]: - is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" +def get_issue_assignees(bot: GitHubTransportContext, issue_number: int) -> list[str] | None: + is_pr = _is_pull_request(bot) 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 [] @@ -351,15 +596,42 @@ 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) + remove_pr_reviewer(bot, issue_number, username) + return remove_assignee(bot, 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: + 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..428e7aee3 100644 --- a/scripts/reviewer_bot_lib/lease_lock.py +++ b/scripts/reviewer_bot_lib/lease_lock.py @@ -1,25 +1,70 @@ """Reviewer-bot lease lock helpers.""" -import json -import os -import random -import time -import uuid from datetime import datetime, timezone from typing import Any +from . import lock_codec, 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 +from .context import LeaseLockContext, LeaseLockRuntimeContext + + +def _log(bot: LeaseLockRuntimeContext, level: str, message: str, **fields: Any) -> None: + bot.logger.event(level, message, **fields) + + +def _sleep(bot: LeaseLockRuntimeContext, seconds: float) -> None: + bot.sleeper.sleep(seconds) + + +def _jitter(bot: LeaseLockRuntimeContext, lower: float, upper: float) -> float: + return bot.jitter.uniform(lower, upper) + + +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) + + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=_BotJitter(), + ) + + +def _now(bot: LeaseLockRuntimeContext) -> datetime: + return bot.clock.now() + + +def _monotonic(bot: LeaseLockRuntimeContext) -> float: + return bot.time.monotonic() + + +def _uuid4_hex(bot: LeaseLockRuntimeContext) -> str: + return bot.uuid_source.uuid4_hex() + + +def _lock_lease_ttl_seconds(bot: LeaseLockRuntimeContext) -> int: + return bot.lock_lease_ttl_seconds() + + +def _lock_api_retry_limit(bot: LeaseLockRuntimeContext) -> int: + return bot.lock_api_retry_limit() + + +def _lock_retry_base_seconds(bot: LeaseLockRuntimeContext) -> float: + return bot.lock_retry_base_seconds() + + +def _lock_max_wait_seconds(bot: LeaseLockRuntimeContext) -> int: + return bot.lock_max_wait_seconds() + + +def _lock_renewal_window_seconds(bot: LeaseLockRuntimeContext) -> int: + return bot.lock_renewal_window_seconds() def lock_is_currently_valid(bot: LeaseLockContext, lock_meta: dict, now: datetime | None = None) -> bool: @@ -33,33 +78,33 @@ 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) - return bot.normalize_lock_metadata( + acquired_at = _now(bot) + expires_at = acquired_at.timestamp() + _lock_lease_ttl_seconds(bot) + return lock_codec.normalize_lock_metadata( { "schema_version": 1, "lock_state": "locked", @@ -74,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: @@ -87,7 +132,7 @@ def normalize_lock_ref_name(ref_name: str) -> str: def get_lock_ref_name(bot: LeaseLockContext) -> str: - 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: @@ -163,9 +208,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 @@ -179,24 +228,23 @@ 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: 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: @@ -208,10 +256,11 @@ 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) + default_branch = bot.lock_ref_bootstrap_branch() 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 +285,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 +305,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}" @@ -295,32 +354,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( @@ -331,62 +395,75 @@ 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, + if retrying.is_retryable_status(create_response.status_code): + delay = _retry_delay(bot, retry_base, attempt) + _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, + 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", + "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 " @@ -394,7 +471,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( @@ -418,13 +495,16 @@ 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})" + if retrying.is_retryable_status(create_response.status_code): + _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_delay(bot, retry_base, attempt) + _sleep(bot, delay) continue if create_response.status_code in {401, 403}: raise RuntimeError( @@ -459,12 +539,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_delay(bot, retry_base, attempt) + _sleep(bot, delay) continue conflicting_token = snapshot_lock.get("lock_token") if isinstance(snapshot_lock, dict) else None raise RuntimeError( @@ -472,10 +555,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}: @@ -483,11 +563,8 @@ 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: - print( - "Retryable lease lock acquire failure " - f"(status {update_response.status_code}); retrying (attempt {attempt})" - ) + 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( "Unexpected status while acquiring reviewer-bot lease lock " @@ -496,12 +573,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_delay(bot, retry_base, attempt) + _sleep(bot, delay) def release_state_issue_lease_lock(bot: LeaseLockContext) -> bool: @@ -509,88 +590,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_delay(bot, retry_base, attempt) + _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, + if retrying.is_retryable_status(create_response.status_code): + _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_delay(bot, retry_base, attempt) + _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, + if update_response.status_code in {409, 422} or retrying.is_retryable_status(update_response.status_code): + _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_delay(bot, retry_base, attempt) + _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/scripts/reviewer_bot_lib/lifecycle.py b/scripts/reviewer_bot_lib/lifecycle.py index bad3765ae..a0ab894df 100644 --- a/scripts/reviewer_bot_lib/lifecycle.py +++ b/scripts/reviewer_bot_lib/lifecycle.py @@ -3,11 +3,34 @@ from __future__ import annotations import hashlib -import json -import os +from copy import deepcopy +from dataclasses import dataclass 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 + + +def _log(bot, level: str, message: str, **fields) -> None: + bot.logger.event(level, message, **fields) + + +@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: @@ -18,10 +41,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() @@ -31,7 +50,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"): @@ -45,9 +64,9 @@ 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 - bot.reviews_module.record_transition_notice_sent( + record_transition_notice_sent( review_data, bot.datetime.now(bot.timezone.utc).isoformat(), ) @@ -56,7 +75,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) @@ -68,61 +90,62 @@ 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: 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", "") - reviewer = bot.get_next_reviewer(state, skip_usernames={issue_author} if issue_author else set()) + issue_author = request.issue_author + reviewer = bot.adapters.queue.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 = _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) + is_pr = request.is_pull_request + 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): - 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") - failure_comment = bot.get_assignment_failure_comment(reviewer, assignment_attempt) + 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.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 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 = 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() - 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: @@ -133,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, @@ -145,18 +168,21 @@ 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: 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) @@ -164,22 +190,31 @@ 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") + _log(bot, "info", "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 = 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() + head_sha = request.head_sha 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( + timestamp = request.event_created_at or _now_iso() + changed = accept_channel_event( review_data, "contributor_revision", semantic_key=f"pull_request_sync:{issue_number}:{head_sha}", @@ -187,29 +222,81 @@ 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) - return changed + 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") + 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) -> 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 - changed = bot.reviews_module.accept_channel_event( + return HeadObservationRepairResult(changed=True, outcome="changed") + changed = accept_channel_event( review_data, "contributor_revision", semantic_key=f"pull_request_head_observed:{issue_number}:{head_sha}", @@ -223,12 +310,15 @@ 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: 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/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/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 4f7e2dd1e..72106ec58 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -2,12 +2,12 @@ from __future__ import annotations -import json -import os - import yaml -from .lifecycle import maybe_record_head_observation_repair +from . import automation +from .context import PrivilegedCommandRequest +from .event_inputs import build_manual_dispatch_request +from .lifecycle import handle_transition_notice, maybe_record_head_observation_repair from .overdue import ( backfill_transition_notice_if_present, check_overdue_reviews, @@ -18,13 +18,87 @@ 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 +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() +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): + 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 _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, + *, + 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), + } + existing_marker = review_data.get("repair_needed") + if _repair_marker_matches(existing_marker, 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 @@ -32,12 +106,13 @@ 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) 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 @@ -51,10 +126,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): @@ -81,16 +155,16 @@ 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.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(): 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 = 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(): @@ -112,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) @@ -125,13 +199,30 @@ 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.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) 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) + 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" + return True + request = build_revalidated_privileged_command_request( + bot, + issue_number=issue_number, + actor=actor, + command_name=str(command_name), + labels=labels, + ) + 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" @@ -149,15 +240,69 @@ 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 - if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data): + try: + 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"): + changed = True + except Exception as exc: + _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, + 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: + _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, + 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 @@ -170,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/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/overdue.py b/scripts/reviewer_bot_lib/overdue.py index 4e2ecd0d3..c0c8171de 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: @@ -18,15 +22,20 @@ 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): - response_state = bot.reviews_module.compute_reviewer_response_state( - bot, + 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 + if isinstance(issue_snapshot.get("pull_request"), dict): + response_state = bot.adapters.review_state.compute_reviewer_response_state( issue_number, review_data, issue_snapshot=issue_snapshot, @@ -53,11 +62,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,10 +173,11 @@ 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.github.post_comment(issue_number, warning_message): + return False 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/project_board.py b/scripts/reviewer_bot_lib/project_board.py index 3b21aafd2..a5c226572 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 @@ -15,6 +14,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, @@ -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,9 +120,8 @@ class BoardPreviewResult: noop_reason: str | None -def reviewer_board_enabled(bot) -> bool: - del bot - return os.environ.get(REVIEWER_BOARD_ENABLED_ENV, "false").strip().lower() == "true" +def reviewer_board_enabled(bot: ProjectBoardMetadataContext) -> bool: + return bot.get_config_value(REVIEWER_BOARD_ENABLED_ENV, "false").strip().lower() == "true" def _field_type_name(field_node: dict[str, Any]) -> str: @@ -137,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 @@ -191,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(): @@ -209,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, @@ -258,9 +258,14 @@ 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( + derived = bot.adapters.review_state.compute_reviewer_response_state( issue_number, preview_review_data, issue_snapshot=copy.deepcopy(issue_snapshot), @@ -273,9 +278,15 @@ 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) + 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}") @@ -339,7 +350,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 @@ -357,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/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 b25759b95..65fb29b91 100644 --- a/scripts/reviewer_bot_lib/reconcile.py +++ b/scripts/reviewer_bot_lib/reconcile.py @@ -2,23 +2,76 @@ from __future__ import annotations -import json -import os - -from .comment_routing import ( - _digest_body, - _handle_command, - _record_conversation_freshness, - classify_comment_payload, +from copy import deepcopy +from dataclasses import dataclass + +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 .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, +) +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, + record_reviewer_activity, ) from .reviews import ( find_triage_approval_after, + rebuild_pr_approval_state_result, refresh_reviewer_review_from_live_preferred_review, ) +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() + return bot.clock.now().isoformat() + + +@dataclass(frozen=True) +class LiveCommentReplayValidationResult: + live_classified: dict | None + changed: bool + failed_closed: bool def _ensure_source_event_key(review_data: dict, source_event_key: str, payload: dict | None = None) -> None: @@ -29,16 +82,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: @@ -47,12 +105,17 @@ 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}") - reviews = bot.get_pull_request_reviews(issue_number) - if reviews is None: - raise RuntimeError(f"Failed to fetch live reviews for PR #{issue_number}") + pull_request = _read_reconcile_object(bot, f"pulls/{issue_number}", label=f"pull request #{issue_number}") + 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"), + "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, @@ -61,10 +124,29 @@ 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}") - return bool(completion.get("completed")) + approval_result = 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"] + 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( @@ -75,19 +157,21 @@ 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") 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 - state_changed = bot.maybe_record_head_observation_repair(issue_number, review_data) - reviews = bot.get_pull_request_reviews(issue_number) - if reviews is None: + 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) + 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( @@ -118,6 +202,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 = 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.github.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", @@ -200,238 +322,138 @@ 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 _set_env_if_present(name: str, value) -> None: - if value is None: - return - os.environ[name] = str(value) - - -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") - 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) - os.environ["IS_PULL_REQUEST"] = "true" - os.environ["ISSUE_AUTHOR"] = author_login - os.environ["ISSUE_LABELS"] = json.dumps(label_names) - return pull_request - - -def _hydrate_reconcile_comment_context(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") - 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") - _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" - - -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 _load_deferred_context(bot) -> dict: + return bot.load_deferred_payload() -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") -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, - 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")) - _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")) - live_comment = bot.github_api("GET", live_comment_endpoint) - if not isinstance(live_comment, dict): + payload = context.payload.raw_payload + comment_id = context.comment_id + pr_number = context.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_context: LiveCommentReplayContext | None = None, *, comment_body: str = "") -> CommentEventRequest: + return CommentEventRequest( + issue_number=pr_number, + is_pull_request=True, + issue_author=pr_context.issue_author, + comment_id=comment_id, + 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=(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=( + comment_context.comment_performed_via_github_app if comment_context is not None else False + ), + ) + + 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) - _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}.") - return changed - _hydrate_reconcile_comment_context(live_comment, payload) + 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. " + 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}." + ) + gap_changed = _update_deferred_gap( + bot, + review_data, + payload, + "reconcile_failed_closed", + summary, + failure_kind=exc.failure_kind, + ) + return changed or gap_changed + 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"): + if digest_comment_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) - _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 + 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, 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 - 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 + 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, + payload, + live_body, + ) + if validation_result.live_classified is None: + return changed or validation_result.changed + live_classified = validation_result.live_classified + if context.payload.comment_class in {"command_only", "command_plus_text"}: + 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 -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, +) -> 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, @@ -447,12 +469,20 @@ 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, } ) + 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", "")) @@ -460,7 +490,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, @@ -471,10 +501,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, @@ -484,10 +514,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, @@ -497,89 +527,90 @@ 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: 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() - pr_number = int(payload.get("pr_number", 0) or 0) + payload = _load_deferred_context(bot) + 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) + 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 = 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) - print( - "Observer workflow produced explicit no-op payload for " - f"{source_event_key}: {payload.get('reason')}" + if isinstance(parsed_payload, ObserverNoopPayload): + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) + _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 - 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(bot, 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, - payload, - expected_event_name="issue_comment", - live_comment_endpoint=f"issues/comments/{payload['comment_id']}", + context, ) - 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(bot, 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, - payload, - expected_event_name="pull_request_review_comment", - live_comment_endpoint=f"pulls/comments/{payload['comment_id']}", + context, ) - 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 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}") + if event_name == "pull_request_review" and event_action == "submitted" and isinstance(parsed_payload, DeferredReviewPayload): + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) + context = build_deferred_review_replay_context( + parsed_payload, + expected_event_action="submitted", + ) + review_id = context.review_id + 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 = 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", "")) - changed = bot.maybe_record_head_observation_repair(pr_number, review_data) + live_commit_id = parsed_payload.source_commit_id + actor = context.actor_login + 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): - bot.reviews_module.accept_channel_event( + accept_channel_event( review_data, "reviewer_review", semantic_key=source_event_key, @@ -588,33 +619,44 @@ 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_review_rebuild(bot, state, pr_number, review_data) - _mark_reconciled_source_event(review_data, source_event_key) - _clear_source_event_key(review_data, source_event_key) - return changed or True - - 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 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( + record_reviewer_activity(review_data, live_submitted_at) + state_changed = True + if _record_review_rebuild(bot, state, pr_number, review_data): + state_changed = True + 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" and isinstance(parsed_payload, DeferredReviewPayload): + _validate_workflow_run_artifact_identity(bot, parsed_payload.raw_payload) + context = build_deferred_review_replay_context( + parsed_payload, + expected_event_action="dismissed", + ) + accept_channel_event( review_data, "review_dismissal", semantic_key=source_event_key, 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.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) _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 + gap_changed = _update_deferred_gap( + bot, + review_data, + payload, + "reconcile_failed_closed", + 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/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")), + ) 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/scripts/reviewer_bot_lib/review_state.py b/scripts/reviewer_bot_lib/review_state.py new file mode 100644 index 000000000..38628574f --- /dev/null +++ b/scripts/reviewer_bot_lib/review_state.py @@ -0,0 +1,360 @@ +"""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 ( + _pull_request_read_result, + 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]: + 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, + 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 661d8718e..95f7b408d 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -6,21 +6,122 @@ 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, 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 ( + 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, +) + + +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 + + +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() +def _projection_failure(reason: str, failure_kind: str | None = None) -> dict[str, object]: + 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): + 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: + 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) + 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 _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} + 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: + return _fallback_pull_request_reviews_result(bot, issue_number) + 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.github.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 @@ -58,11 +159,11 @@ 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: - 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 @@ -100,11 +201,11 @@ 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: - 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] = [] @@ -152,8 +253,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)] @@ -186,7 +285,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"], @@ -207,6 +306,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, @@ -229,10 +333,119 @@ def refresh_reviewer_review_from_live_preferred_review( changed = True submitted_at = preferred_review.get("submitted_at") if isinstance(submitted_at, str): - record_reviewer_activity(review_data, submitted_at) + 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"] + + 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 = 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, + permission_statuses=permission_cache, + ) + if not result.get("ok"): + return _projection_failure(str(result.get("reason"))) + 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]: + 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, + 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, + *, + 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, @@ -288,7 +501,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"] = {} @@ -375,33 +588,37 @@ 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: +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 @@ -440,7 +657,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, *, @@ -476,26 +693,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, @@ -506,11 +723,14 @@ 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: - 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() @@ -520,23 +740,23 @@ 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: 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): + if bot.github.post_comment(issue_number, MANDATORY_TRIAGE_ESCALATION_TEMPLATE): review_data["mandatory_approver_pinged_at"] = now state_changed = True return state_changed 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"): @@ -548,25 +768,17 @@ 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) - bot.post_comment(issue_number, MANDATORY_TRIAGE_SATISFIED_TEMPLATE.format(approver=approver)) + _log(bot, "warning", f"Unable to remove escalation label on #{issue_number}: {exc}", issue_number=issue_number, error=str(exc)) + bot.github.post_comment(issue_number, MANDATORY_TRIAGE_SATISFIED_TEMPLATE.format(approver=approver)) return True 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]: @@ -587,7 +799,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: @@ -595,6 +807,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, @@ -603,93 +816,47 @@ def rebuild_pr_approval_state( pull_request: dict | None = None, reviews: list[dict] | None = None, ) -> tuple[dict | None, dict | None]: - 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 - 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: + 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") - 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, bool] = {} - 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] = bot.check_user_permission(author, "push") - if permission_cache[cache_key]: - 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, +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]: + 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 completion, write_approval - def pr_has_current_write_approval( bot, @@ -760,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) @@ -797,7 +964,20 @@ 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"): + 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,25 +998,28 @@ 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"} - 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, - ) + 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 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, + 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) + elif refresh_live_review: + reviewer_review = None latest_reviewer_response = reviewer_comment if _compare_records(reviewer_review, latest_reviewer_response) > 0: @@ -847,12 +1030,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, @@ -860,14 +1045,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, @@ -875,9 +1058,15 @@ 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) + 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": "live_review_state_unknown"} + return {"state": "projection_failed", "reason": approval_failure or "live_review_state_unknown"} if not completion.get("completed"): return { "state": "awaiting_contributor_response", @@ -920,13 +1109,13 @@ 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": 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") @@ -936,15 +1125,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: @@ -954,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): @@ -971,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" @@ -997,11 +1178,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") @@ -1034,7 +1224,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") @@ -1042,7 +1232,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/scripts/reviewer_bot_lib/reviews_projection.py b/scripts/reviewer_bot_lib/reviews_projection.py new file mode 100644 index 000000000..aae4279f1 --- /dev/null +++ b/scripts/reviewer_bot_lib/reviews_projection.py @@ -0,0 +1,131 @@ +"""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 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], + *, + 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/scripts/reviewer_bot_lib/runtime.py b/scripts/reviewer_bot_lib/runtime.py new file mode 100644 index 000000000..64dc4a1a7 --- /dev/null +++ b/scripts/reviewer_bot_lib/runtime.py @@ -0,0 +1,450 @@ +"""Explicit runtime service composition for reviewer-bot orchestration.""" + +from __future__ import annotations + +import json +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +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, + 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, +) + + +class _EnvConfig: + 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 _FileOutputSink: + def __init__(self, config: _EnvConfig): + 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 _JsonDeferredPayloadLoader: + def __init__(self, config: _EnvConfig): + self._config = config + + 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 _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) + + def drain(self) -> list[int]: + touched = sorted(self._touched) + self._touched.clear() + 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 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 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 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 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.""" + + 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, + *, + requests: Any, + sys: Any, + random: Any, + time: Any, + config: Any | None = None, + outputs: Any | None = None, + 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, + uuid_source: Any | None = None, + logger: 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, + ): + self.requests = requests + self.sys = sys + self.random = random + self.time = time + 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: + return self.config.get(name, default) + + 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_ENV, str(STATE_ISSUE_NUMBER)) or 0) + + def lock_api_retry_limit(self) -> int: + 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(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(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(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(LOCK_RENEWAL_WINDOW_SECONDS_ENV, str(LOCK_RENEWAL_WINDOW_SECONDS)) or 0) + + def lock_ref_name(self) -> str: + return self.get_config_value(LOCK_REF_NAME_ENV, LOCK_REF_NAME) + + def lock_ref_bootstrap_branch(self) -> str: + 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) + + def load_deferred_payload(self) -> dict: + return self.deferred_payloads.load() + + 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) + + 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 assert_lock_held(self, context: str) -> None: + return self.adapters.state_lock.assert_lock_held(context) + + def get_github_token(self) -> str: + return self.adapters.github.get_github_token() + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: + 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.github_graphql(query, variables, token=token) + + # Adapter-only mutable review-state compatibility surface. + def normalize_lock_metadata(self, lock_meta: dict | None): + return self.adapters.state_lock.normalize_lock_metadata(lock_meta) + + def get_state_issue(self): + return self.adapters.state_lock.get_state_issue() + + def clear_lock_metadata(self): + return self.adapters.state_lock.clear_lock_metadata() + + def get_state_issue_snapshot(self): + return self.adapters.state_lock.get_state_issue_snapshot() + + def conditional_patch_state_issue(self, body: str, etag: str | None = None): + return self.adapters.state_lock.conditional_patch_state_issue(body, etag) + + def parse_lock_metadata_from_issue_body(self, body: str): + 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.state_lock.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.state_lock.get_state_issue_html_url() + + def get_lock_ref_display(self): + return self.adapters.state_lock.get_lock_ref_display() + + def get_lock_ref_snapshot(self): + return self.adapters.state_lock.get_lock_ref_snapshot() + + def build_lock_metadata(self, *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.state_lock.create_lock_commit(parent_sha, tree_sha, lock_meta) + + def cas_update_lock_ref(self, new_sha: str): + 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.state_lock.lock_is_currently_valid(lock_meta, now) + + def renew_state_issue_lease_lock(self, 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.state_lock.ensure_state_issue_lease_lock_fresh() + + def acquire_state_issue_lease_lock(self): + 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.state_lock.release_state_issue_lease_lock() + self.ACTIVE_LEASE_CONTEXT = self.adapters.state_lock.get_active_lease_context() + return result diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index b65d74b26..546f3eaf5 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -1,45 +1,81 @@ """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 import yaml +from . import lock_codec, 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, ) -from .context import StateStoreContext +from .context import StateStoreContext, StateStoreRuntimeContext + + +def _log(bot: StateStoreRuntimeContext, level: str, message: str, **fields: Any) -> None: + bot.logger.event(level, message, **fields) + + +def _sleep(bot: StateStoreRuntimeContext, seconds: float) -> None: + bot.sleeper.sleep(seconds) + + +def _jitter(bot: StateStoreRuntimeContext, lower: float, upper: float) -> float: + return bot.jitter.uniform(lower, upper) + + +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) + + return retrying.bounded_exponential_delay( + base_seconds, + retry_attempt, + jitter=_BotJitter(), + ) + + +def _now_iso(bot: StateStoreRuntimeContext) -> str: + return bot.clock.now().isoformat() + + +def _state_issue_number(bot: StateStoreRuntimeContext) -> int: + return bot.state_issue_number() + + +def _lock_api_retry_limit(bot: StateStoreRuntimeContext) -> int: + return bot.lock_api_retry_limit() + + +def _lock_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: + return bot.lock_retry_base_seconds() + + +def _state_read_retry_limit(bot: StateStoreRuntimeContext) -> int: + return bot.state_read_retry_limit() + + +def _state_read_retry_base_seconds(bot: StateStoreRuntimeContext) -> float: + return bot.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 = getattr(bot, "STATE_ISSUE_NUMBER", STATE_ISSUE_NUMBER) - 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_issue_number = _state_issue_number(bot) + 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: - 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 +87,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 retrying.is_retryable_status(response.status_code): 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 = _retry_delay(bot, state_read_retry_base_seconds, attempt) + _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 @@ -156,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: @@ -190,8 +220,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) + except yaml.YAMLError: state = {} if not isinstance(state, dict): @@ -203,21 +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 as exc: - print(f"WARNING: Failed to parse lock metadata JSON: {exc}", file=sys.stderr) - 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: @@ -250,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" @@ -271,26 +280,29 @@ 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( "GET", f"issues/{state_issue_number}", + retry_policy="idempotent_read", 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") @@ -299,18 +311,20 @@ 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", f"issues/{state_issue_number}", {"body": body}, + extra_headers=extra_headers, suppress_error_log=True, ) @@ -340,7 +354,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) @@ -368,19 +382,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() @@ -392,58 +406,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 = _retry_delay(bot, lock_retry_base_seconds, attempt) + _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 retrying.is_retryable_status(response.status_code): 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 = _retry_delay(bot, lock_retry_base_seconds, attempt) + _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/scripts/reviewer_bot_lib/sweeper.py b/scripts/reviewer_bot_lib/sweeper.py index a04394927..2447b06e5 100644 --- a/scripts/reviewer_bot_lib/sweeper.py +++ b/scripts/reviewer_bot_lib/sweeper.py @@ -4,12 +4,15 @@ import io import json -import os 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, _artifact_expected_payload_name, @@ -18,6 +21,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: @@ -28,7 +39,32 @@ def _now_iso() -> str: return _now().isoformat() +def _retention_days(bot: SweeperContext) -> int: + return int(bot.get_config_value("DEFERRED_ARTIFACT_RETENTION_DAYS", "7") or 7) + + +def _github_repository(bot: SweeperContext) -> str: + return bot.get_config_value("GITHUB_REPOSITORY", "") + + 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 @@ -41,6 +77,26 @@ def parse_timestamp(value: Any) -> datetime | None: return 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: + 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: SweeperContext, retry_attempt: int) -> float: + 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: + bot.sleeper.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") @@ -67,9 +123,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" @@ -81,6 +136,7 @@ def classify_artifact_gap_reason(gap: dict, now: datetime | None = None) -> str: def correlate_candidate_observer_runs( + bot: SweeperContext, source_event_key: str, *, source_event_kind: str, @@ -140,7 +196,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: @@ -216,13 +272,13 @@ 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="") 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: @@ -236,18 +292,18 @@ 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: - response = bot.github_api("GET", f"actions/runs/{run_id}") +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: - 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 @@ -259,21 +315,37 @@ 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") 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(bot.lock_api_retry_limit()) + 1 + response = None + for attempt in range(1, max_attempts + 1): + try: + response = bot.artifact_download_transport.download( + download_url, + headers={ + "Authorization": f"Bearer {bot.get_github_token()}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + except Exception: + if attempt < max_attempts: + _sleep(bot, _download_retry_delay(bot, attempt)) + continue + return "download_unavailable", None + if retrying.is_retryable_status(response.status_code): + if attempt < max_attempts: + _sleep(bot, _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: @@ -290,7 +362,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] = {} @@ -387,6 +459,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" @@ -410,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) @@ -441,7 +591,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 +620,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 @@ -520,7 +670,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 @@ -534,18 +684,18 @@ 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) - _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 + 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 def _discover_visible_comment_events(bot, issue_number: int, review_data: dict) -> tuple[list[dict] | None, bool]: @@ -583,7 +733,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) @@ -616,7 +766,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): @@ -650,7 +800,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) @@ -755,7 +905,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: @@ -767,7 +917,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") @@ -782,66 +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( - 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: @@ -853,6 +959,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, @@ -925,66 +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( - 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: @@ -992,64 +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( - 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/__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..e0b513409 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace + +import pytest + +from tests.fixtures import reviewer_bot_recorders +from tests.fixtures.reviewer_bot_env import ( + build_test_lease_context, + clear_reviewer_bot_env, +) + + +@pytest.fixture(autouse=True) +def reset_reviewer_bot_process_state_fixture(): + with pytest.MonkeyPatch().context() as monkeypatch: + clear_reviewer_bot_env(monkeypatch) + 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 build_test_lease_context() + + +@pytest.fixture +def tmp_deferred_path(tmp_path): + return tmp_path / "deferred-context.json" + + +@pytest.fixture +def captured_comments(): + return reviewer_bot_recorders.record_comment_dicts(SimpleNamespace()) + + +@pytest.fixture +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 new file mode 100644 index 000000000..0eefe8a19 --- /dev/null +++ b/tests/contract/reviewer_bot/test_adapter_contract.py @@ -0,0 +1,385 @@ +from types import SimpleNamespace +from typing import get_type_hints + +import pytest + +pytestmark = pytest.mark.contract + +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, StdErrLogger +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.reviewer_bot import make_state + + +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_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 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") + 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(runtime), runtime) + + 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) + + 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().adapters.review_state.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_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_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).github.request_reviewer_assignment(42, "alice") + + runtime.set_config_value("IS_PULL_REQUEST", "false") + 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 + 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"]') + 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") + 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" + + +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_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") + + 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.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" + + +def test_runtime_accepts_injected_infra_services(): + clock = object() + sleeper = object() + 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, + 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 + assert runtime.rest_transport is rest_transport + 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 + + +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 + + +def test_bootstrap_runtime_wires_explicit_state_github_and_lock_services(): + runtime = reviewer_bot._runtime_bot() + + 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 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_bootstrap_runtime_wires_explicit_adapter_services(): + runtime = reviewer_bot._runtime_bot() + + assert hasattr(runtime.adapters, "github") + 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_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") + + +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_comment_application_contract.py b/tests/contract/reviewer_bot/test_comment_application_contract.py new file mode 100644 index 000000000..66cd24684 --- /dev/null +++ b/tests/contract/reviewer_bot/test_comment_application_contract.py @@ -0,0 +1,59 @@ +import pytest + +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 + +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 = review_state.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(harness.runtime, 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 = review_state.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.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 + + changed = comment_application.process_comment_event( + harness.runtime, + state, + request, + classify_comment_payload=comment_routing.classify_comment_payload, + classify_issue_comment_actor=comment_routing.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/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..6704d0c9c --- /dev/null +++ b/tests/contract/reviewer_bot/test_fake_github_transport_contract.py @@ -0,0 +1,149 @@ +import pytest + +from tests.fixtures.reviewer_bot_fakes 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}] + + +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/contract/reviewer_bot/test_fake_runtime_contract.py b/tests/contract/reviewer_bot/test_fake_runtime_contract.py new file mode 100644 index 000000000..969efc84e --- /dev/null +++ b/tests/contract/reviewer_bot/test_fake_runtime_contract.py @@ -0,0 +1,246 @@ +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, + WorkflowBehaviorStub, + build_default_handler_map, +) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi + +pytestmark = pytest.mark.contract + + +def test_fake_runtime_config_writes_round_trip_locally(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_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) + + +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.logger 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 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 + + +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 == [] + assert hasattr(runtime, "review_state_module") is False + assert hasattr(runtime, "reviews_module") is False + + +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_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) + + 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.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 + assert runtime.touch_tracker is not None + + +def test_fake_runtime_review_state_compatibility_surface_is_limited(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) + + with pytest.raises(AssertionError, match="Unsupported runtime handler override"): + runtime.handlers.stub("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) + + 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"}} + + +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) + assert isinstance(runtime.workflow, WorkflowBehaviorStub) + + +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 + 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") + + +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/contract/reviewer_bot/test_privileged_commands_workflow.py b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py new file mode 100644 index 000000000..428fae62f --- /dev/null +++ b/tests/contract/reviewer_bot/test_privileged_commands_workflow.py @@ -0,0 +1,16 @@ +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( + 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_review_state_contract.py b/tests/contract/reviewer_bot/test_review_state_contract.py new file mode 100644 index 000000000..05bd55bc4 --- /dev/null +++ b/tests/contract/reviewer_bot/test_review_state_contract.py @@ -0,0 +1,152 @@ +from pathlib import Path + +import pytest + +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() + + 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] + + +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") + 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", + "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 + 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_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 "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", + "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) is False 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..f06f795bf --- /dev/null +++ b/tests/contract/reviewer_bot/test_reviewer_board_workflow_contracts.py @@ -0,0 +1,30 @@ +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)) + 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_runtime_protocols.py b/tests/contract/reviewer_bot/test_runtime_protocols.py new file mode 100644 index 000000000..b44e622a6 --- /dev/null +++ b/tests/contract/reviewer_bot/test_runtime_protocols.py @@ -0,0 +1,58 @@ +import pytest + +pytestmark = pytest.mark.contract + +from scripts import reviewer_bot +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 + + +def _assert_core_runtime_surface(runtime) -> None: + assert hasattr(runtime, "get_config_value") + 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(): + 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) + assert isinstance(runtime, EventInputsContext) + 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): + runtime = FakeReviewerBotRuntime(monkeypatch) + + assert isinstance(runtime, AppEventContextRuntime) + assert isinstance(runtime, AppExecutionRuntime) + assert isinstance(runtime, EventInputsContext) + _assert_core_runtime_surface(runtime) 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..b0988696c --- /dev/null +++ b/tests/contract/reviewer_bot/test_support_layer_ownership.py @@ -0,0 +1,70 @@ +import pytest + +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 + + +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(): + assert http_responses.FakeGitHubResponse is not None + assert http_responses.__all__ == ["FakeGitHubResponse"] + + +def test_reviewer_bot_recorders_module_remains_available_for_shared_recorders(): + assert reviewer_bot_recorders is not None + + +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", + } 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..688aa737e --- /dev/null +++ b/tests/contract/reviewer_bot/test_workflow_artifact_contracts.py @@ -0,0 +1,161 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +pytestmark = pytest.mark.contract + +from scripts.reviewer_bot_lib import reconcile + + +@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 reconcile._expected_observer_identity(payload) == ( + workflow_name, + workflow_file, + ) + 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") + 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, + } + + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + + with pytest.raises(RuntimeError, match="Triggering workflow name mismatch"): + 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") + 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, + } + + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + + with pytest.raises(RuntimeError, match="run_attempt mismatch"): + 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") + 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, + } + + bot = SimpleNamespace(get_config_value=lambda name, default="": __import__("os").environ.get(name, default)) + + with pytest.raises(RuntimeError, match="did not conclude successfully"): + 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 new file mode 100644 index 000000000..b97bf9563 --- /dev/null +++ b/tests/contract/reviewer_bot/test_workflow_files.py @@ -0,0 +1,149 @@ +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" + ) + 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 + +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 + +def test_build_pr_comment_observer_payload_marks_trusted_direct_same_repo_as_observer_noop(monkeypatch): + from scripts.reviewer_bot_lib import comment_routing + from tests.fixtures.comment_routing_harness import CommentRoutingHarness + + 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", + { + "head": {"repo": {"full_name": "rustfoundation/safety-critical-rust-coding-guidelines"}}, + "user": {"login": "PLeVasseur"}, + }, + ) + + 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" + assert payload["source_event_key"] == "issue_comment:100" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 000000000..c0970cc35 --- /dev/null +++ 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/app_harness.py b/tests/fixtures/app_harness.py new file mode 100644 index 000000000..be4329113 --- /dev/null +++ b/tests/fixtures/app_harness.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +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 + + +@dataclass +class MainRun: + exit_code: int | None + context: EventContext | None = None + + +class AppHarness: + def __init__(self, monkeypatch): + self._monkeypatch = 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 + + def set_event(self, **values) -> None: + set_env_values(self.config, **values) + + def set_state_sequence(self, *states: dict) -> None: + self.runtime.stub_state_sequence(*states) + + def stub_load_state(self, func) -> None: + self.state_store.stub_load(func) + + def stub_save_state(self, func) -> 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_handler(self, name: str, func) -> None: + self.handlers.stub(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: ExecutionResult) -> MainRun: + captured = MainRun(exit_code=None) + + def fake_execute_run(bot, context): + captured.context = context + return result + + self._monkeypatch.setattr(app, "execute_run", fake_execute_run) + return captured + + def run_execute(self): + return reviewer_bot.execute_run(reviewer_bot.build_event_context(self.runtime), self.runtime) + + def run_main(self) -> MainRun: + try: + reviewer_bot.main(self.runtime) + 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/fixtures/commands_harness.py b/tests/fixtures/commands_harness.py new file mode 100644 index 000000000..0c71d5cff --- /dev/null +++ b/tests/fixtures/commands_harness.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import subprocess + +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 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 +from .reviewer_bot_recorders import record_comments + + +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.runtime = FakeReviewerBotRuntime(monkeypatch) + self.config = self.runtime.config + self.github = self.runtime.github + self.handlers = self.runtime.handlers + + def wrapper_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: + 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: + values["COMMENT_AUTHOR_ASSOCIATION"] = author_association + if workflow_file: + values["CURRENT_WORKFLOW_FILE"] = workflow_file + if repository: + values["GITHUB_REPOSITORY"] = repository + if ref: + values["GITHUB_REF"] = ref + set_env_values(self.config, **values) + + 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 wrapper_set_privileged_context( + self, + *, + labels: list[str], + is_pull_request: bool = False, + target_repo_root=None, + ) -> None: + import json + + 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 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) + + 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: AssignmentAttempt( + success=success, status_code=status_code + ) + + 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.handlers.stub(name, func) + + def automation_runner(self) -> AutomationRunner: + runner = AutomationRunner() + 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(self.runtime, issue_number=issue_number) + + 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 = ""): + return event_inputs.build_privileged_command_request( + self.runtime, + issue_number=issue_number, + actor=actor, + command_name=command_name, + ) + + 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=request or self.assignment_request(issue_number=issue_number), + ) + + 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=request or self.assignment_request(issue_number=issue_number), + ) + + 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=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, *, request=None): + return commands_module.handle_pass_until_command( + self.runtime, + state, + issue_number, + comment_author, + return_date, + reason, + request=request or self.assignment_request(issue_number=issue_number), + ) + + 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=request or self.assignment_request(issue_number=issue_number), + ) + + 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=request or 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, *, request=None): + return automation_module.handle_accept_no_fls_changes_command( + self.runtime, + issue_number, + comment_author, + 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, *, request: CommentEventRequest | None = None, trust_context: PrCommentTrustContext | None = None): + return comment_routing_module.handle_comment_event( + self.runtime, + state, + 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): + 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 new file mode 100644 index 000000000..7c9dc7e53 --- /dev/null +++ b/tests/fixtures/comment_routing_harness.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from scripts.reviewer_bot_lib import comment_routing, event_inputs +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 + + +class CommentRoutingHarness: + def __init__(self, monkeypatch): + self._monkeypatch = 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) + + def env_build_trust_context(self): + return event_inputs.build_pr_comment_trust_context(self.runtime) + + 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.env_build_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.env_build_request(issue_number=issue_number), + trust_context or self.env_build_trust_context(), + ) + + 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 build_comment_event_request( + 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, + comment_sender_type="", + comment_installation_id="", + comment_performed_via_github_app=False, + ) + + 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 build_pr_comment_trust_context( + 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 capture_comment_side_effects(self): + return record_comment_side_effects(self.runtime) + + def wrapper_apply_inputs( + 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: + 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: + values["COMMENT_AUTHOR_ASSOCIATION"] = comment_author_association + if current_workflow_file: + values["CURRENT_WORKFLOW_FILE"] = current_workflow_file + if github_repository: + values["GITHUB_REPOSITORY"] = github_repository + 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/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_runtime.py b/tests/fixtures/fake_runtime.py new file mode 100644 index 000000000..6a35ce6f6 --- /dev/null +++ b/tests/fixtures/fake_runtime.py @@ -0,0 +1,809 @@ +from __future__ import annotations + +import random +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 +from scripts.reviewer_bot_lib import commands as commands_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 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 ( + 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, + FLS_AUDIT_LABEL, + REVIEW_FRESHNESS_RUNBOOK_PATH, + REVIEWER_REQUEST_422_TEMPLATE, + STATUS_PROJECTION_EPOCH, + TRANSITION_PERIOD_DAYS, + AssignmentAttempt, + 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, + DeferredPayloadStore, + GitHubStub, + GraphQLTransportStub, + HandlerStub, + LockStub, + OutputCapture, + RestTransportStub, + StateStoreStub, + TouchTrackerStub, + WorkflowBehaviorStub, + build_default_handler_map, +) +from tests.fixtures.recording_logger import RecordingLogger + + +class FakeRuntimeInfraServices: + 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 + self.logger = logger + 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 + + +class FakeRuntimeDomainServices: + 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: + 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): + 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) + + +def runtime_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 + + 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): + 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) + + 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 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 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 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 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): + 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) + + +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): + 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 = 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 = 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 = 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 = 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 = 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 = 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 = runtime_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): + 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 + 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 = 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 + 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.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", + 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() + self.github = GitHubStub(github) + self.github.bind_runtime(self) + 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.workflow = WorkflowBehaviorStub() + self._fetch_members = lambda: [] + self.compat = SimpleNamespace( + github=FakeRuntimeGitHubCompatibility(self), + review=FakeRuntimeReviewCompatibility(self), + state_lock=FakeRuntimeStateLockCompatibility(self), + automation=FakeRuntimeAutomationCompatibility(self), + ) + self.adapters = FakeRuntimeAdapterServices(self) + self.handlers = HandlerStub(build_default_handler_map(self)) + 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, + clock=self.clock, + sleeper=self.sleeper, + jitter=self.jitter, + uuid_source=self.uuid_source, + touch_tracker=self.touch_tracker, + ) + self.domain = FakeRuntimeDomainServices( + state_store=self.state_store, + github=self.github, + locks=self.locks, + handlers=self.handlers, + workflow=self.workflow, + adapters=self.adapters, + compat=self.compat, + ) + + 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 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 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) + + 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) + + 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.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 ensure_state_issue_lease_lock_fresh(self) -> bool: + return self.compat.state_lock.ensure_state_issue_lease_lock_fresh() + + def acquire_state_issue_lease_lock(self): + return self.compat.state_lock.acquire_state_issue_lease_lock() + + def release_state_issue_lease_lock(self) -> bool: + 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) + + def sync_members_with_queue(self, state: dict): + return self.workflow.sync_members_with_queue(state) + + def sync_status_labels_for_items(self, state: dict, 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) + + def get_issue_assignees(self, issue_number: int): + return self.compat.github.get_issue_assignees(issue_number) + + def request_reviewer_assignment(self, issue_number: int, username: str): + return self.compat.github.request_reviewer_assignment(issue_number, username) + + def get_assignment_failure_comment(self, reviewer: str, attempt): + return self.compat.github.get_assignment_failure_comment(reviewer, attempt) + + def post_comment(self, issue_number: int, body: str) -> bool: + return self.compat.github.post_comment(issue_number, body) + + def add_reaction(self, comment_id: int, reaction: str) -> bool: + return self.compat.github.add_reaction(comment_id, reaction) + + def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: + return self.compat.github.get_user_permission_status(username, required_permission) + + def check_user_permission(self, username: str, required_permission: str = "triage"): + return self.compat.github.check_user_permission(username, required_permission) + + def get_repo_labels(self): + return self.compat.github.get_repo_labels() + + def add_label(self, issue_number: int, label: str) -> bool: + return self.compat.github.add_label(issue_number, label) + + def remove_label(self, issue_number: int, label: str) -> bool: + return self.compat.github.remove_label(issue_number, label) + + def remove_assignee(self, issue_number: int, username: str) -> bool: + return self.compat.github.remove_assignee(issue_number, username) + + def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: + return self.compat.github.remove_pr_reviewer(issue_number, username) + + def unassign_reviewer(self, issue_number: int, username: str) -> bool: + return self.compat.github.unassign_reviewer(issue_number, username) + + def get_issue_or_pr_snapshot(self, issue_number: int) -> dict | None: + return self.compat.github.get_issue_or_pr_snapshot(issue_number) + + def get_pull_request_reviews(self, issue_number: int): + return self.compat.github.get_pull_request_reviews(issue_number) + + def list_open_items_with_status_labels(self) -> list[int]: + return self.compat.review.list_open_items_with_status_labels() + + def maybe_record_head_observation_repair(self, issue_number: int, review_data: dict): + 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 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 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 self.compat.review.get_next_reviewer(state, skip_usernames) + + def strip_code_blocks(self, comment_body: str) -> str: + return self.compat.review.strip_code_blocks(comment_body) + + def parse_command(self, comment_body: str): + return self.compat.review.parse_command(comment_body) + + def record_assignment(self, state: dict, github: str, issue_number: int, kind: str) -> None: + return self.compat.review.record_assignment(state, github, issue_number, kind) + + def reposition_member_as_next(self, state: dict, username: str) -> bool: + return self.compat.review.reposition_member_as_next(state, username) + + def parse_iso8601_timestamp(self, value: Any): + return self.compat.state_lock.parse_iso8601_timestamp(value) + + def compute_reviewer_response_state(self, issue_number: int, state: dict, *, issue_snapshot=None): + 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: + 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, + review_data, + pull_request=pull_request, + reviews=reviews, + ) + + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: + 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 self.compat.github.github_graphql(query, variables, token=token) + + def handle_transition_notice(self, state: dict, issue_number: int, reviewer: str) -> bool: + 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 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 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 self.compat.review.handle_label_command(state, issue_number, label_string, request=request) + + def handle_sync_members_command(self, state: dict): + return self.compat.review.handle_sync_members_command(state) + + def handle_queue_command(self, state: dict): + return self.compat.review.handle_queue_command(state) + + def 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 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 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 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 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 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 self.compat.review.handle_rectify_command(state, issue_number, comment_author) + + def get_commands_help(self) -> str: + return self.compat.review.get_commands_help() + + def parse_issue_labels(self) -> list[str]: + return self.compat.automation.parse_issue_labels() + + def fetch_members(self): + return self.compat.automation.fetch_members() + + def run_command(self, command, cwd, check=False): + return self.compat.automation.run_command(command, cwd, check=check) + + def summarize_output(self, result, limit: int = 20) -> str: + return self.compat.automation.summarize_output(result, limit=limit) + + def list_changed_files(self, repo_root): + return self.compat.automation.list_changed_files(repo_root) + + def get_default_branch(self) -> str: + return self.compat.automation.get_default_branch() + + def find_open_pr_for_branch_status(self, branch: str): + return self.compat.automation.find_open_pr_for_branch_status(branch) + + def create_pull_request(self, branch: str, base: str, issue_number: int): + 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) + + def handle_labeled_event(self, state: dict) -> bool: + return self.handlers.call("handle_labeled_event", state) + + def handle_issue_edited_event(self, state: dict) -> bool: + return self.handlers.call("handle_issue_edited_event", state) + + def handle_closed_event(self, state: dict) -> bool: + return self.handlers.call("handle_closed_event", state) + + def handle_pull_request_target_synchronize(self, state: dict) -> bool: + return self.handlers.call("handle_pull_request_target_synchronize", state) + + def handle_pull_request_review_event(self, state: dict) -> bool: + return self.handlers.call("handle_pull_request_review_event", state) + + def handle_comment_event(self, state: dict) -> bool: + return self.handlers.call("handle_comment_event", state) + + def handle_manual_dispatch(self, state: dict) -> bool: + return self.handlers.call("handle_manual_dispatch", state) + + def handle_scheduled_check(self, state: dict) -> bool: + return self.handlers.call("handle_scheduled_check", state) + + 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 = 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 = 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( + method, + endpoint, + data=data, + extra_headers=extra_headers, + **kwargs, + ) + + def collect_touched_item(self, issue_number: int) -> None: + self.touch_tracker.collect(issue_number) + + def drain_touched_items(self) -> list[int]: + 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_deferred_payload(self, payload: dict) -> None: + self.deferred_payloads.set_payload(payload) + + def stub_state_sequence(self, *states: dict) -> None: + self.state_store.stub_state_sequence(*states) + + def stub_state_unavailable(self, message: str = "state unavailable") -> None: + self.state_store.stub_state_unavailable(message) + + def record_saves(self, snapshots: list) -> None: + self.state_store.record_saves(snapshots) + + def stub_pass_until(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self.workflow.stub_pass_until(func) + + def stub_sync_members(self, func: Callable[[dict], tuple[dict, list[str]]]) -> None: + self.workflow.stub_sync_members(func) + + 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/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/focused_fake_services.py b/tests/fixtures/focused_fake_services.py new file mode 100644 index 000000000..651d7e4f7 --- /dev/null +++ b/tests/fixtures/focused_fake_services.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import json +from copy import deepcopy +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 + + +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 + self.load_calls: list[dict[str, Any]] = [] + self.save_calls: list[dict] = [] + + def stub_load(self, func: Callable[..., dict]) -> None: + self._load = func + + 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: + 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) + + +class LockStub: + 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: + self._acquire = acquire + if release is not None: + self._release = release + if refresh is not 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() + + +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 + + 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, + ) + + 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): + self._github = github_stub + 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( + { + "method": method, + "url": url, + "headers": headers, + "json_data": json_data, + "timeout_seconds": timeout_seconds, + } + ) + 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: + endpoint = parsed.path.lstrip("/") + result = self._github.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._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): + raise self._payload + return self._payload + + return _Response(result) + + +class GraphQLTransportStub: + def __init__(self): + 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( + { + "url": url, + "headers": headers, + "query": query, + "variables": variables, + "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] | 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) + + +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) + + 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): + 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 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 + 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: + 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 + + +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), + } diff --git a/tests/fixtures/http_responses.py b/tests/fixtures/http_responses.py new file mode 100644 index 000000000..37516840a --- /dev/null +++ b/tests/fixtures/http_responses.py @@ -0,0 +1,15 @@ +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 + + +__all__ = ["FakeGitHubResponse"] diff --git a/tests/fixtures/reconcile_harness.py b/tests/fixtures/reconcile_harness.py new file mode 100644 index 000000000..a62736fa0 --- /dev/null +++ b/tests/fixtures/reconcile_harness.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from dataclasses import dataclass + +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 +from .reviewer_bot_env import set_env_values +from .reviewer_bot_fakes import RouteGitHubApi, github_result + + +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_application.digest_comment_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_application.digest_comment_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.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) + + 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.deferred_payloads.set_payload(payload) + self.wrapper_set_trigger_from_payload(payload) + return payload + + 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"], + WORKFLOW_RUN_TRIGGERING_ID=payload["source_run_id"], + WORKFLOW_RUN_TRIGGERING_ATTEMPT=payload["source_run_attempt"], + WORKFLOW_RUN_TRIGGERING_CONCLUSION=conclusion, + ) + + set_trigger_from_payload = wrapper_set_trigger_from_payload + + 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.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( + reconcile, + "_record_review_rebuild", + 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/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/fixtures/reviewer_bot.py b/tests/fixtures/reviewer_bot.py new file mode 100644 index 000000000..8243822b4 --- /dev/null +++ b/tests/fixtures/reviewer_bot.py @@ -0,0 +1,145 @@ +import io +import json +import zipfile + +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, +) +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 = { + "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": [ + {"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/tests/fixtures/reviewer_bot_builders.py b/tests/fixtures/reviewer_bot_builders.py new file mode 100644 index 000000000..d0765b10a --- /dev/null +++ b/tests/fixtures/reviewer_bot_builders.py @@ -0,0 +1,268 @@ +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( + 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 = 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: + 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 review_state.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 review_state.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 review_state.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 review_state.accept_channel_event( + review_data, + "contributor_revision", + semantic_key=semantic_key, + timestamp=timestamp, + actor=actor, + reviewed_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/fixtures/reviewer_bot_env.py b/tests/fixtures/reviewer_bot_env.py new file mode 100644 index 000000000..0a00d9124 --- /dev/null +++ b/tests/fixtures/reviewer_bot_env.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from scripts.reviewer_bot_lib.context import LeaseContext + +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 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 clear_reviewer_bot_env(monkeypatch) -> None: + for name in CLEAR_REVIEWER_BOT_ENV_VARS: + monkeypatch.delenv(name, 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)) diff --git a/tests/fixtures/reviewer_bot_fakes.py b/tests/fixtures/reviewer_bot_fakes.py new file mode 100644 index 000000000..5e680a3ed --- /dev/null +++ b/tests/fixtures/reviewer_bot_fakes.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from scripts.reviewer_bot_lib.config import GitHubApiResult + + +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, +) -> 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 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], 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, + endpoint: str, + result: GitHubApiResult | None = None, + **result_kwargs, + ) -> "RouteGitHubApi": + 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": + 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": + 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 + + 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, + ) -> 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) + 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( + GitHubCall(method=method, endpoint=endpoint, data=data) + ) + key = (method, endpoint) + if key in self._api_routes: + 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: + return {} + return route.payload 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/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/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..44df84cef --- /dev/null +++ b/tests/integration/reviewer_bot/test_accept_no_fls_changes.py @@ -0,0 +1,142 @@ +import pytest + +pytestmark = pytest.mark.integration + +from builder import build_cli +from scripts.reviewer_bot_lib import automation +from scripts.reviewer_bot_lib.config import FLS_AUDIT_LABEL +from tests.fixtures.commands_harness import CommandHarness + + +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 automation.list_changed_files(tmp_path) == [] + 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): + 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 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) + 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,), + target_repo_root=str(tmp_path), + ) + harness.stub_permission("granted") + observed = {"cwd": None} + + def fake_list_changed_files(repo_root): + observed["cwd"] = repo_root + return ["README.md"] + + harness.runtime.list_changed_files = fake_list_changed_files + + 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) + 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,), + target_repo_root=str(tmp_path), + ) + harness.stub_permission("granted") + list_calls = {"count": 0} + + def fake_list_changed_files(repo_root): + list_calls["count"] += 1 + assert repo_root == tmp_path + return [] + + 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"]) + + harness.runtime.list_changed_files = fake_list_changed_files + + 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 + 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): + harness = CommandHarness(monkeypatch) + 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,), + target_repo_root=str(tmp_path), + ) + 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"], + returncode=1, + stderr="error: lockfile at uv.lock needs to be updated, but --locked was provided", + ) + + message, success = harness.handle_accept_no_fls_changes(42, "alice", request=request) + + 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/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..8420c37a8 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py @@ -0,0 +1,87 @@ +import pytest + +from scripts.reviewer_bot_lib import review_state +from tests.fixtures.app_harness import AppHarness +from tests.fixtures.reviewer_bot import make_state + +pytestmark = pytest.mark.integration + + +def test_execute_run_closed_issue_comment_cleanup_persists_removed_review_entry(monkeypatch): + 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 = review_state.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 + + 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() + + 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): + 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 = [] + + 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() + + assert result.exit_code == 0 + assert save_called["value"] is False + assert sync_calls == [[42]] 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..02d2e3553 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_execution.py @@ -0,0 +1,105 @@ +import pytest + +from tests.fixtures.app_harness import AppHarness +from tests.fixtures.reviewer_bot import make_state + +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") + + 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 + harness.runtime.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 + + 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() + + 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): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") + 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() + + assert result.exit_code == 1 + assert result.state_changed is True + +def test_execute_run_returns_failure_for_invalid_workflow_run_context(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_run", + EVENT_ACTION="completed", + WORKFLOW_RUN_EVENT="pull_request_review", + WORKFLOW_RUN_EVENT_ACTION="submitted", + ) + 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() + + 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 new file mode 100644 index 000000000..ab336da76 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_reviewer_board_preview.py @@ -0,0 +1,128 @@ +import pytest + +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 + +pytestmark = pytest.mark.integration + + +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", + ) + + 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() + + 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): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="workflow_dispatch", + EVENT_ACTION="", + MANUAL_ACTION="preview-reviewer-board", + REVIEWER_BOARD_ENABLED="true", + ) + monkeypatch.setattr(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) + + 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() + + assert result.exit_code == 1 + 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) + 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(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) + + 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( + harness.runtime, + "github_graphql", + lambda query, variables=None, *, token=None: { + "data": { + "organization": { + "projectV2": { + "id": "PVT_kwDOB", + "title": "Reviewer Board", + "fields": {"nodes": []}, + } + } + } + }, + ) + + result = harness.run_execute() + + assert result.exit_code == 1 + 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) + 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(harness.runtime, "_reviewer_board_project_metadata", None, raising=False) + + state = make_state() + state["status_projection_epoch"] = "status_projection_v1" + 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" + review["active_cycle_started_at"] = "2026-03-20T12:34:56Z" + + 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() + + assert result.exit_code == 0 + output = capsys.readouterr().out + assert "classification: open_tracked_assigned" in output + assert "ensure_membership: true" in output 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..6f268fb29 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py @@ -0,0 +1,145 @@ +import pytest + +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 + +pytestmark = pytest.mark.integration + +def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_state(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") + state = make_state() + review = review_state.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 + + 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(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(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", + ), + ) + 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 = harness.run_execute() + + 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): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") + state = make_state() + 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["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 GitHubApiResult( + 200, + {"state": "open", "head": {"sha": "head-1"}}, + {}, + "ok", + True, + None, + 0, + None, + ) + if endpoint.startswith("pulls/42/reviews"): + return 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) + + 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(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( + maintenance, + "maybe_record_head_observation_repair", + lambda bot, issue_number, review_data: lifecycle.HeadObservationRepairResult( + changed=False, + outcome="unchanged", + ), + ) + harness.stub_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 + ) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) + + result = harness.run_execute() + + 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, + } + ] 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..b97a6cabc --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_schedule_repairs.py @@ -0,0 +1,152 @@ +import json + +import pytest + +pytestmark = pytest.mark.integration + +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 + + +def test_execute_run_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") + state = make_state() + state["status_projection_epoch"] = "status_projection_v1" + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + synced_issue_numbers = [] + saved_epochs = [] + + 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 = harness.run_execute() + + assert result.exit_code == 0 + assert synced_issue_numbers == [42, 99] + 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + saved_epochs = [] + + 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 = harness.run_execute() + + assert result.exit_code == 0 + 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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): + 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 + + 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)) + + result = harness.run_execute() + + 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): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="") + state = make_state() + 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" + 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): + review_state.record_reviewer_activity( + current["active_reviews"]["42"], + "2026-03-18T01:09:05Z", + ) + return False + + 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(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( + lambda current: saved_warning_values.append(current["active_reviews"]["42"]["transition_warning_sent"]) or True + ) + harness.stub_sync_status_labels(lambda current, issue_numbers: False) + + first = harness.run_execute() + second = harness.run_execute() + + 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/integration/reviewer_bot/test_app_state_unavailable.py b/tests/integration/reviewer_bot/test_app_state_unavailable.py new file mode 100644 index 000000000..3bf060217 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_state_unavailable.py @@ -0,0 +1,61 @@ +import pytest + +from tests.fixtures.app_harness import AppHarness + +pytestmark = pytest.mark.integration + + +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.stub_lock(acquire=lambda: None, release=lambda: True) + harness.runtime.stub_state_unavailable("state unavailable") + + 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): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="issue_comment", EVENT_ACTION="created") + harness.stub_lock(acquire=lambda: None, release=lambda: True) + + called = { + "pass_until": False, + "sync": False, + "handler": False, + "save": False, + } + + 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 + + harness.runtime.stub_state_unavailable("state unavailable") + 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() + + assert result.exit_code == 1 + assert called == { + "pass_until": False, + "sync": False, + "handler": False, + "save": False, + } 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..acf170cad --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_workflow_run_bookkeeping.py @@ -0,0 +1,244 @@ +import json + +import pytest + +pytestmark = pytest.mark.integration + +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 + + +def test_execute_run_workflow_run_bookkeeping_only_reconcile_still_saves_state(tmp_path, monkeypatch): + 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 = 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"} + + 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", + ) + 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 = [] + + 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, + ) + 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 + ) + harness.stub_sync_status_labels(lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True) + + result = harness.run_execute() + + 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 +): + 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 = 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"} + + 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": comment_routing._digest_body("@guidelines-bot /queue"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "bob", + } + ), + encoding="utf-8", + ) + 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 = [] + + 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 + ) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) + + result = harness.run_execute() + + 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 +): + 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 = 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"} + + 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": comment_routing._digest_body("review comment body"), + "source_created_at": "2026-03-17T10:00:00Z", + "actor_login": "alice", + } + ), + encoding="utf-8", + ) + 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 = [] + + 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_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"]), + "gap_present": "pull_request_review_comment:310" + in current["active_reviews"]["42"]["deferred_gaps"], + } + ) + or True + ) + harness.stub_sync_status_labels(lambda current, issue_numbers: True) + + result = harness.run_execute() + + assert result.exit_code == 0 + assert save_snapshots == [ + {"reconciled": ["pull_request_review_comment:310"], "gap_present": False} + ] 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..ee538a665 --- /dev/null +++ b/tests/integration/reviewer_bot/test_app_workflow_run_paths.py @@ -0,0 +1,104 @@ +import pytest + +from scripts.reviewer_bot_lib.context import LeaseContext +from tests.fixtures.app_harness import AppHarness +from tests.fixtures.reviewer_bot import make_state + +pytestmark = pytest.mark.integration + + +def test_execute_run_cross_repo_review_does_not_acquire_lock(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="pull_request_review", EVENT_ACTION="submitted", 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") + + 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() + + assert acquire_called["value"] is False + +def test_execute_run_same_repo_review_does_not_acquire_lock(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="pull_request_review", 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") + + 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() + + assert acquire_called["value"] is False + +def test_execute_run_workflow_run_reconcile_acquires_lock(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event(EVENT_NAME="workflow_run", EVENT_ACTION="completed", WORKFLOW_RUN_EVENT="pull_request_review") + + acquire_called = {"value": False} + + def fake_acquire(): + acquire_called["value"] = True + return 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", + ) + + 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) + + 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) + harness.set_event(EVENT_NAME="workflow_run", EVENT_ACTION="completed", WORKFLOW_RUN_EVENT="pull_request_review_comment") + + acquire_called = {"value": False} + + def fake_acquire(): + acquire_called["value"] = True + return 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", + ) + + 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) + + result = harness.run_execute() + + assert acquire_called["value"] is True + assert result.exit_code == 0 diff --git a/tests/integration/reviewer_bot/test_cli_entrypoint.py b/tests/integration/reviewer_bot/test_cli_entrypoint.py new file mode 100644 index 000000000..22039912f --- /dev/null +++ b/tests/integration/reviewer_bot/test_cli_entrypoint.py @@ -0,0 +1,110 @@ +import pytest + +from scripts import reviewer_bot +from tests.fixtures.app_harness import AppHarness +from tests.fixtures.reviewer_bot import make_state + +pytestmark = pytest.mark.integration + + +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", + ) + harness.stub_load_state(lambda *, fail_on_unavailable=False: make_state()) + + run = harness.run_main() + + assert run.exit_code is None + output = capsys.readouterr().out + assert "Current state:" in output + assert "freshness_runtime_epoch" in output + + +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", + ) + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=0, state_changed=False) + ) + + run = harness.run_main() + + 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_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", + ) + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=0, state_changed=False) + ) + + run = harness.run_main() + + 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_exits_with_nonzero_execution_result(monkeypatch): + harness = AppHarness(monkeypatch) + harness.set_event( + EVENT_NAME="issue_comment", + EVENT_ACTION="created", + ) + captured = harness.stub_execute_run( + reviewer_bot.ExecutionResult(exit_code=1, state_changed=False) + ) + + run = harness.run_main() + + assert run.exit_code == 1 + 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" + + +def test_app_harness_no_longer_requires_singleton_runtime_patch(monkeypatch): + AppHarness(monkeypatch) + + assert hasattr(reviewer_bot, "RUNTIME") is False diff --git a/tests/integration/reviewer_bot/test_comment_routing_integration.py b/tests/integration/reviewer_bot/test_comment_routing_integration.py new file mode 100644 index 000000000..c6623b138 --- /dev/null +++ b/tests/integration/reviewer_bot/test_comment_routing_integration.py @@ -0,0 +1,152 @@ +import pytest + +pytestmark = pytest.mark.integration + +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 + + +def test_handle_non_pr_issue_comment_creates_pending_privileged_command(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + state = make_state() + entry = review_state.ensure_review_entry(state, 42, create=True) + assert entry is not None + entry["current_reviewer"] = "alice" + 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.capture_comment_side_effects() + 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 + 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() + 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 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): + harness = CommentRoutingHarness(monkeypatch) + state = make_state() + 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.capture_comment_side_effects() + 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 + 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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 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): + harness = CommentRoutingHarness(monkeypatch) + state = make_state() + 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 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + request = harness.request( + 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 harness.handle_comment_event(state, request=request) 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() + 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, + } + ) + 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 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_lifecycle_integration.py b/tests/integration/reviewer_bot/test_lifecycle_integration.py new file mode 100644 index 000000000..c355d2daa --- /dev/null +++ b/tests/integration/reviewer_bot/test_lifecycle_integration.py @@ -0,0 +1,56 @@ +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 + + +def test_workflow_run_review_submission_clears_warning_and_transition_notice_markers(monkeypatch): + state = make_state() + 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" + 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, + ), + ) + 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", + ) + harness.add_reviews_page( + pr_number=42, + reviews=[ + { + "id": 11, + "submitted_at": "2026-03-17T10:00:00Z", + "state": "COMMENTED", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + ) + + 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_privileged_command.py b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py new file mode 100644 index 000000000..ff695f139 --- /dev/null +++ b/tests/integration/reviewer_bot/test_reconcile_privileged_command.py @@ -0,0 +1,121 @@ +import os + +import pytest + +pytestmark = pytest.mark.integration + +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 + + +def test_execute_pending_privileged_command_revalidates_live_state(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", + "status": "pending", + } + 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" + 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" + +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) + 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", + } + harness.set_manual_dispatch(source_event_key="issue_comment:100") + 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, request=None): + observed["issue_number"] = issue_number + observed["actor"] = actor + observed["request"] = request + return ("ok", True) + + 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"] + 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", + "status": "pending", + } + 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" + 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 + +def test_execute_pending_privileged_command_fails_closed_without_live_fls_audit_label(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", + "status": "pending", + } + 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( + 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 + pending = review["pending_privileged_commands"]["issue_comment:100"] + assert pending["status"] == "failed_closed" + assert pending["result"] == "live_revalidation_failed" 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..b43efe46d --- /dev/null +++ b/tests/integration/reviewer_bot/test_reconcile_workflow_run.py @@ -0,0 +1,604 @@ +import pytest + +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 + + +def test_handle_workflow_run_event_returns_true_for_submitted_review_bookkeeping_only_mutations( + monkeypatch, +): + state = make_state() + review = make_tracked_review_state(state, 42, reviewer="bob") + review["deferred_gaps"]["pull_request_review:11"] = {"reason": "artifact_missing"} + 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, + ), + ) + 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", + ) + + 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(monkeypatch): + state = make_state() + 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, + ), + ) + 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 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(monkeypatch): + state = make_state() + review = make_tracked_review_state(state, 42, reviewer="alice") + review["deferred_gaps"]["issue_comment:210"] = {"reason": "artifact_missing"} + live_body = "@guidelines-bot /queue" + 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, + ), + ) + 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", + ) + harness.stub_apply_comment_command(False) + + 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(monkeypatch): + state = make_state() + 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, + ), + ) + 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 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(monkeypatch): + state = make_state() + 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, + ), + ) + 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", + ) + 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", + ) + ], + ) + 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 + + +def test_handle_workflow_run_event_refreshes_stale_stored_reviewer_review_to_current_head_preferred_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, + ) + 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, + ), + ) + 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", + ), + ], + ) + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" + + 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(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "author reply in review thread" + 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, + ), + ) + 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", + ) + 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"] + assert accepted is not None + assert accepted["semantic_key"] == "pull_request_review_comment:301" + + +def test_deferred_review_comment_reconcile_records_reviewer_freshness(monkeypatch): + state = make_state() + review = make_tracked_review_state(state, 42, reviewer="alice") + live_body = "reviewer reply in thread" + 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, + ), + ) + 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", + ) + 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" + + +def test_deferred_review_comment_missing_live_object_preserves_source_time_freshness(monkeypatch): + state = make_state() + 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, + ), + ) + 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", + ) + 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" + 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(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "@guidelines-bot /claim" + 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, + ), + ) + 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", + ) + harness.stub_comment_classification( + { + "comment_class": "command_only", + "has_non_command_text": False, + "command_count": 2, + "command": None, + "args": [], + "normalized_body": live_body, + } + ) + command_calls = [] + + 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 == [] + 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(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "reviewer-bot validation: contributor plain text comment" + 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, + ), + ) + 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", + ) + 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" + assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None + + +def test_deferred_comment_reconcile_uses_pr_assignment_semantics_for_claim(monkeypatch): + state = make_state() + state["queue"] = [{"github": "bob", "name": "Bob"}] + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "@guidelines-bot /claim" + 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, + ), + ) + 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", + ) + harness.runtime.get_user_permission_status = lambda username, required_permission="push": "granted" + claim_contexts = [] + + 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, + "is_pull_request": request.is_pull_request, + "issue_author": request.issue_author, + } + ) + 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 + assert claim_contexts == [ + { + "issue_number": 42, + "username": "bob", + "is_pull_request": True, + "issue_author": "dana", + } + ] + assert state["active_reviews"]["42"]["current_reviewer"] == "bob" + + +def test_deferred_comment_reconcile_records_failure_kind_when_live_comment_unavailable(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "reviewer-bot validation: contributor plain text comment" + 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, + ), + ) + 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 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(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + live_body = "reviewer-bot validation: contributor plain text comment" + 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, + ), + ) + 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", + ) + harness.stub_comment_classification( + { + "comment_class": "command_plus_text", + "has_non_command_text": True, + "command_count": 1, + "command": "claim", + "args": [], + "normalized_body": live_body, + } + ) + + 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"] 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..2fb8fd909 --- /dev/null +++ b/tests/unit/reviewer_bot/test_app_event_intent.py @@ -0,0 +1,50 @@ +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): + 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): + 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): + 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): + 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): + 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): + 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): + 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_commands.py b/tests/unit/reviewer_bot/test_commands.py new file mode 100644 index 000000000..280ac0cd5 --- /dev/null +++ b/tests/unit/reviewer_bot/test_commands.py @@ -0,0 +1,398 @@ + +import pytest + +from scripts.reviewer_bot_lib import ( + automation, + commands, + comment_application, + guidance, + reconcile, + 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 + + +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 + review["current_reviewer"] = "alice" + request = harness.typed_comment_request( + issue_number=42, + actor="alice", + body="@guidelines-bot /label +sign-off: create pr", + issue_author="dana", + is_pull_request=False, + ) + harness.runtime.get_repo_labels = lambda: ["sign-off: create pr"] + harness.runtime.add_label = lambda issue_number, label: True + 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 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`")] + + +def test_label_signoff_create_pr_on_pr_does_not_mark_issue_complete(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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", + ref="refs/heads/main", + ) + 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 + 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 harness.handle_comment_event(state, request=request, trust_context=trust_context) 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} + 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"): + automation.create_pull_request(harness.runtime, "feature-branch", "main", 42) + + assert called["post"] == 0 + + +def test_assign_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + harness.runtime.get_issue_assignees = lambda issue_number: None + + response, success = harness.handle_assign(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): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + 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", request=request) + + assert success is True + assert response == "✅ @felix91gr has been assigned as reviewer." + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] + + +def test_claim_command_posts_pr_guidance_on_success(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + 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", request=request) + + assert success is True + assert response == "✅ @felix91gr has claimed this review." + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] + + +def test_pass_command_posts_pr_guidance_for_new_reviewer(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [ + {"github": "alice", "name": "Alice"}, + {"github": "felix91gr", "name": "Félix Fischer"}, + ] + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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, request=request) + + assert success is True + assert "@felix91gr is now assigned as the reviewer." in response + assert posted == [guidance.get_pr_guidance("felix91gr", "PLeVasseur")] + + +def test_assign_from_queue_posts_guidance_only_once(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "felix91gr", "name": "Félix Fischer"}] + 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, request=request) + + assert success is True + assert response == "✅ @felix91gr (next in queue) has been assigned as reviewer." + 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) + 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", request=request) + + assert success is False + assert "Unable to verify triage permissions right now" in message + + +def test_pass_command_fails_closed_when_assignees_unavailable(monkeypatch): + harness = CommandHarness(monkeypatch) + state = make_state() + 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 = harness.handle_pass(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): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}, {"github": "bob", "name": "Bob"}] + 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 = harness.handle_pass_until(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): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + harness.runtime.get_issue_assignees = lambda issue_number: None + + response, success = harness.handle_claim(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): + harness = CommandHarness(monkeypatch) + state = make_state() + harness.runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" + + response, success = harness.handle_release(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): + harness = CommandHarness(monkeypatch) + state = make_state() + harness.runtime.get_issue_assignees = lambda issue_number: None + + response, success = harness.handle_release(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): + harness = CommandHarness(monkeypatch) + state = make_state() + state["queue"] = [{"github": "alice", "name": "Alice"}] + harness.runtime.get_issue_assignees = lambda issue_number: None + + response, success = harness.handle_assign_from_queue(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() + harness = CommandHarness(monkeypatch) + 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") + + 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() + harness = CommandHarness(monkeypatch) + 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") + + 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): + harness = CommentRoutingHarness(monkeypatch) + 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, + is_pull_request=False, + comment_author="alice", + comment_body="@guidelines-bot /accept-no-fls-changes", + ) + + ok, metadata = comment_application.validate_accept_no_fls_changes_handoff( + harness.runtime, + request, + ) + + assert ok is False + assert metadata["reason"] == "authorization_unavailable" + + +def test_manual_dispatch_marks_live_permission_unavailable_for_pending_privileged_command(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", + "status": "pending", + } + } + 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": "unavailable" + + 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" + + +@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): + comment_body = """before +```bash +@guidelines-bot /queue +``` + @guidelines-bot /queue +inline `@guidelines-bot /queue` +after""" + + 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 new file mode 100644 index 000000000..a67343e50 --- /dev/null +++ b/tests/unit/reviewer_bot/test_comment_routing.py @@ -0,0 +1,204 @@ +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 +from tests.fixtures.reviewer_bot import make_state + + +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 + 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" + review_state.accept_channel_event( + review, + "reviewer_comment", + semantic_key="issue_comment:100", + timestamp="2026-03-17T09:00:00Z", + actor="alice", + ) + 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 = comment_application.record_conversation_freshness( + harness.runtime, + state, + request, + ) + + 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): + 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(): + payload = comment_routing.classify_comment_payload( + SimpleNamespace( + BOT_MENTION="@guidelines-bot", + adapters=SimpleNamespace( + commands=SimpleNamespace( + 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 + + +def test_route_issue_comment_trust_allows_only_same_repo_repo_user_principal(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + request = harness.request( + issue_number=42, + is_pull_request=True, + issue_author="carol", + 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="carol", + ) + 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): + 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(harness.runtime, 42, request, trust_context) + + +def test_build_pr_comment_observer_payload_wrapper_uses_explicit_env_facts(monkeypatch): + harness = CommentRoutingHarness(monkeypatch) + harness.wrapper_apply_inputs( + 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 = harness.build_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 + + +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 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] 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..8fc20cc90 --- /dev/null +++ b/tests/unit/reviewer_bot/test_fixture_contracts.py @@ -0,0 +1,155 @@ +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.focused_fake_services import ( + ArtifactDownloadTransportStub, + GitHubStub, + GraphQLTransportStub, + LockStub, + RestTransportStub, + StateStoreStub, + WorkflowBehaviorStub, +) +from tests.fixtures.recording_logger import RecordingLogger +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result + + +def test_shared_github_fixture_exports_core_transport_helpers(): + routes = RouteGitHubApi() + result = github_result(200, {"ok": True}) + + assert routes is not None + assert result.ok is True + + +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.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}, + } + ] + + +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_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}]) + + 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} + + +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", + ] 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..74e88181c --- /dev/null +++ b/tests/unit/reviewer_bot/test_github_api.py @@ -0,0 +1,348 @@ +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 +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): + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [ + github_result(502, {"message": "bad gateway"}), + github_result(200, {"ok": True}), + ], + ) + 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 result.failure_kind is None + assert bot.sleeper.calls == [2.0] + + +def test_github_api_request_retries_transport_exception_for_idempotent_get(monkeypatch): + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [github_api.requests.RequestException("timeout"), github_result(200, {"ok": True})], + ) + 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): + 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 github.requested_endpoints() == ["issues/42"] + + +def test_github_api_request_classifies_forbidden_without_retry(monkeypatch): + 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 github.requested_endpoints() == ["issues/42"] + + +def test_github_graphql_request_retries_idempotent_query_on_502(monkeypatch): + bot = _bot(monkeypatch) + bot.graphql_transport.stub_sequence( + [ + FakeGitHubResponse(502, {"message": "bad gateway"}, "bad gateway"), + FakeGitHubResponse(200, {"data": {"viewer": {"login": "bot"}}}, "ok"), + ] + ) + 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"}}} + assert result.retry_attempts == 1 + + +def test_github_api_request_retries_rate_limit_then_succeeds(monkeypatch): + github = RouteGitHubApi().add_request_sequence( + "GET", + "issues/42", + [ + github_result(429, {"message": "slow down"}), + github_result(200, {"ok": True}), + ], + ) + 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): + 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" + assert result.retry_attempts == LOCK_API_RETRY_LIMIT + + +def test_github_graphql_request_reports_invalid_payload(monkeypatch): + bot = _bot(monkeypatch) + bot.graphql_transport.stub_sequence([FakeGitHubResponse(200, ValueError("bad json"), "bad json")]) + + 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): + bot = _bot(monkeypatch) + bot.graphql_transport.stub_sequence([FakeGitHubResponse(200, {"errors": [{"message": "boom"}]}, "boom")]) + + 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): + 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, "GET", "issues/42", timeout_seconds=12.5) + + assert result.ok is True + assert bot.rest_transport.calls[0]["timeout_seconds"] == 12.5 + + +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(monkeypatch), "POST", "issues/42", retry_policy="idempotent_read") + + +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(monkeypatch), + "mutation { closeIssue(input: {}) { clientMutationId } }", + retry_policy="idempotent_read", + ) + + +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(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(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_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): + 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 [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): + 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(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): + 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): + 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" + assert result.retry_attempts == LOCK_API_RETRY_LIMIT + + +def test_github_api_request_reports_transport_retry_exhaustion(monkeypatch): + 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" + assert result.retry_attempts == LOCK_API_RETRY_LIMIT + assert "timeout" in str(result.transport_error) + + +def test_github_api_request_logs_transport_error_to_recording_logger(monkeypatch): + 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) + + 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): + 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) + + 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): + bot = _bot(monkeypatch) + 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) + + assert result.ok is True + 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(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(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") + + 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): + 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) 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..602c53a28 --- /dev/null +++ b/tests/unit/reviewer_bot/test_lease_lock.py @@ -0,0 +1,392 @@ +import sys +from types import SimpleNamespace + +import pytest + +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): + 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", + LOCK_REF_BOOTSTRAP_BRANCH="main", + 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="": 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.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) + 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(): + bot = _lease_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", + }, + ), + ] + ) + + 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 + ) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) + + context = lease_lock.acquire_state_issue_lease_lock(bot) + + assert context.lock_token == "token-123" + 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}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "other-token"}), + ] + ) + + 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 + ) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) + + with pytest.raises(RuntimeError, match="unexpected lock state"): + lease_lock.acquire_state_issue_lease_lock(bot) + + +def test_acquire_lock_succeeds_when_later_loop_observes_own_valid_token(): + bot = _lease_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", + }, + ), + ] + ) + + 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 + ) + bot.cas_update_lock_ref = lambda new_sha: GitHubApiResult(200, {}, {}, "", True) + + context = lease_lock.acquire_state_issue_lease_lock(bot) + + assert context.lock_token == "token-123" + assert bot.ACTIVE_LEASE_CONTEXT is context + + +def test_acquire_lock_fails_closed_when_own_token_has_mismatched_owner(): + bot = _lease_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", + }, + ) + ] + ) + + bot.get_lock_ref_snapshot = lambda: next(snapshots) + + with pytest.raises(RuntimeError, match="owner metadata drifted"): + lease_lock.acquire_state_issue_lease_lock(bot) + + +def test_release_lock_retries_stale_unlocked_predecessor(monkeypatch): + context = 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", + ) + bot = _lease_bot(ACTIVE_LEASE_CONTEXT=context) + snapshots = iter( + [ + ("stale-ref", "tree", {"lock_state": "unlocked", "lock_token": None}), + ("new-ref", "tree", {"lock_state": "locked", "lock_token": "token-123"}), + ] + ) + + 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 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 = 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", + ) + bot = _lease_bot(ACTIVE_LEASE_CONTEXT=context) + bot.get_lock_ref_snapshot = lambda: ( + "new-ref", + "tree", + {"lock_state": "locked", "lock_token": "other-token"}, + ) + + 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): + 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 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 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 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 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) + + bot = _lease_bot(github_api_request=fake_request) + + 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") + + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_unavailable(monkeypatch): + responses = iter( + [ + GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + GitHubApiResult(502, {"message": "bad gateway"}, {}, "bad gateway", False, "server_error", 1, None), + ] + ) + bot = _lease_bot(github_api_request=lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="Unable to read bootstrap branch"): + lease_lock.ensure_lock_ref_exists(bot) + + +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, + ), + ] + ) + 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) + + +def test_renew_state_issue_lease_lock_fails_on_token_mismatch(monkeypatch): + 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", + ) + bot = _lease_bot() + bot.get_lock_ref_snapshot = lambda: ( + "ref-sha", + "tree-sha", + {"lock_token": "different-token", "lock_state": "locked"}, + ) + + 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): + 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"): + lease_lock.ensure_lock_ref_exists(bot) + + +def test_ensure_lock_ref_exists_fails_closed_when_bootstrap_branch_sha_missing(monkeypatch): + responses = iter( + [ + GitHubApiResult(404, {"message": "missing"}, {}, "missing", False, "not_found", 0, None), + GitHubApiResult(200, {"object": {}}, {}, "ok", True, None, 0, None), + ] + ) + bot = _lease_bot(github_api_request=lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="Bootstrap branch ref did not include SHA"): + lease_lock.ensure_lock_ref_exists(bot) + + +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), + ] + ) + 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) diff --git a/tests/unit/reviewer_bot/test_lifecycle.py b/tests/unit/reviewer_bot/test_lifecycle.py new file mode 100644 index 000000000..744a9c1d1 --- /dev/null +++ b/tests/unit/reviewer_bot/test_lifecycle.py @@ -0,0 +1,345 @@ +import json + +import pytest + +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 = 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"] + 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 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 = review_state.ensure_review_entry(state, 42, create=True) + assert entry is not None + entry["current_reviewer"] = "alice" + 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 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 = 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" + 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 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + posted = [] + runtime.post_comment = lambda issue_number, body: posted.append((issue_number, body)) or True + + assert lifecycle.handle_transition_notice(runtime, state, 42, "alice") is True + assert review["transition_notice_sent_at"] is not None + 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() + review_state.ensure_review_entry(state, 42, create=True) + posted = [] + runtime.post_comment = lambda issue_number, body: posted.append(body) or 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 = 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" + 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 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 = 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(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( + maintenance, + "check_overdue_reviews", + lambda bot, state: [ + { + "issue_number": 42, + "reviewer": "alice", + "days_overdue": 20, + "days_since_warning": 15, + "needs_warning": False, + "needs_transition": True, + } + ], + ) + runtime.get_pull_request_reviews = lambda issue_number: [] + runtime.get_issue_or_pr_snapshot = lambda issue_number: {"pull_request": {}} + posted = [] + 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): + 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 = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) + + assert result == lifecycle.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): + 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=404, + payload={"message": "missing"}, + headers={}, + text="missing", + ok=False, + failure_kind="not_found", + retry_attempts=0, + transport_error=None, + ) + + 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}} + 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 = 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}} + 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 = 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}, + "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 = [] + 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 = lifecycle.maybe_record_head_observation_repair(runtime, 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): + runtime = FakeReviewerBotRuntime(monkeypatch) + state = make_state() + 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"): + 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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): + 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 = lifecycle.maybe_record_head_observation_repair(runtime, 42, review_data) + + assert result.changed is True + assert result.outcome == "changed" + assert review_data["active_head_sha"] == "head-1" 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" diff --git a/tests/unit/reviewer_bot/test_maintenance.py b/tests/unit/reviewer_bot/test_maintenance.py new file mode 100644 index 000000000..6c67bd259 --- /dev/null +++ b/tests/unit/reviewer_bot/test_maintenance.py @@ -0,0 +1,131 @@ +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 = 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" + 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" + else [ + { + "id": 10, + "state": "COMMENTED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ], + {}, + "ok", + True, + None, + 0, + None, + ) + 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" + 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 = 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 = [] + 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 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", + "reason": "stale", + "failure_kind": "server_error", + "recorded_at": "2026-03-01T00:00:00Z", + } + return lifecycle.HeadObservationRepairResult(changed=False, outcome="unchanged") + + monkeypatch.setattr(maintenance, "maybe_record_head_observation_repair", fake_head_repair) + + 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" + 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 = 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"]) + bot = FakeReviewerBotRuntime(monkeypatch) + monkeypatch.setattr(maintenance, "_now_iso", lambda bot: next(timestamps)) + + first = maintenance._record_maintenance_repair_marker( + bot, + review, + phase="head_observation_repair", + reason="pull_request_unavailable", + failure_kind="server_error", + ) + second = maintenance._record_maintenance_repair_marker( + 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 = review_state.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", + } + 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 maintenance.handle_scheduled_check(bot, state) is True + assert review["repair_needed"] is None 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"] diff --git a/tests/unit/reviewer_bot/test_overdue.py b/tests/unit/reviewer_bot/test_overdue.py new file mode 100644 index 000000000..cfc052559 --- /dev/null +++ b/tests/unit/reviewer_bot/test_overdue.py @@ -0,0 +1,159 @@ +from datetime import timedelta + +from scripts.reviewer_bot_lib import maintenance, review_state, reviews +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +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, +) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi + + +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.github.stub(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") + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + 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 = 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" + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: None + + assert maintenance.check_overdue_reviews(runtime, state) == [] + + +def test_handle_overdue_review_warning_only_records_successful_comment(monkeypatch): + state = make_state() + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.post_comment = lambda issue_number, body: 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): + 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") + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")) + 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): + 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") + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-2")).add_pull_request_reviews(42, []) + 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 + + +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") + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews(42, []) + 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") + 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"), + ], + ) + 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_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", + "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" + routes = RouteGitHubApi().add_request("GET", "pulls/42", status_code=200, payload=pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [{"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 + 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_project_board.py b/tests/unit/reviewer_bot/test_project_board.py new file mode 100644 index 000000000..060d4fc3a --- /dev/null +++ b/tests/unit/reviewer_bot/test_project_board.py @@ -0,0 +1,173 @@ +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.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, +) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi + + +def _runtime(monkeypatch, routes=None): + runtime = FakeReviewerBotRuntime(monkeypatch) + if routes is not None: + runtime.github.stub(routes) + return runtime + + +def test_reviewer_board_preflight_validates_manifest(monkeypatch): + 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 = project_board.reviewer_board_preflight(runtime) + + assert preflight.enabled is True + assert preflight.valid is True + 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") + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") + + preview = project_board.preview_board_projection_for_item(runtime, 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() + make_tracked_review_state(state, 42) + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") + + 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 + + +def test_preview_board_projection_closed_item_maps_to_archive_intent(monkeypatch): + state = make_state() + make_tracked_review_state(state, 42, reviewer="alice") + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="closed") + + preview = project_board.preview_board_projection_for_item(runtime, state, 42) + + assert preview.classification == "closed" + 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() + runtime = _runtime(monkeypatch) + runtime.get_issue_or_pr_snapshot = lambda issue_number: issue_snapshot(issue_number, state="open") + + preview = project_board.preview_board_projection_for_item(runtime, state, 42) + + assert preview.classification == "open_untracked" + 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) + 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.adapters.review_state.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) + + 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 = 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, + [ + 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"), + ], + ) + 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, _ = reviews.project_status_labels_for_item(runtime, 42, state) + preview = project_board.preview_board_projection_for_item(runtime, state, 42) + + assert desired_labels == {STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL} + assert preview.desired is not None + assert preview.desired.review_state == REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR + + +def test_preview_board_projection_marks_projection_repair_as_attention(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", + 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 = project_board.preview_board_projection_for_item(runtime, state, 42) + + assert preview.desired is not None + assert preview.desired.needs_attention == REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED + + +def test_reviewer_board_manifest_includes_projection_repair_attention_option(): + options = project_board.REVIEWER_BOARD_PROJECT_MANIFEST[REVIEWER_BOARD_FIELD_NEEDS_ATTENTION]["options"] + + assert REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED in options 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..6c2218362 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_artifacts.py @@ -0,0 +1,54 @@ +from scripts.reviewer_bot_lib import reconcile, review_state +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +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): + state = make_state() + review = review_state.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + 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, + ) + ) + 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.github.stub(routes) + + 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 new file mode 100644 index 000000000..3ce43cc1d --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_replay.py @@ -0,0 +1,31 @@ +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 = 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) + + message, success, changed = reconcile.reconcile_active_review_entry( + runtime, + state, + 42, + require_pull_request_context=True, + ) + + assert success is True + assert changed is False + assert "no reconciliation transitions applied" in message 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..f828d7d14 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reconcile_unit.py @@ -0,0 +1,328 @@ +import pytest + +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 = 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) + + assert isinstance(parsed, reconcile.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 = reconcile.parse_deferred_context_payload(payload) + + 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 = 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", + 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 = reconcile.build_deferred_comment_replay_context( + payload, + expected_event_name="issue_comment", + live_comment_endpoint="issues/comments/210", + ) + + 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 = 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", + 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"): + 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 = 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", + 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 = reconcile.build_deferred_review_replay_context( + payload, + expected_event_action="submitted", + ) + + 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 = 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", + 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"): + reconcile.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, + "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 = reconcile.parse_deferred_context_payload(payload) + + 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 = 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) + + message, success, changed = reconcile.reconcile_active_review_entry(runtime, 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"): + reconcile.parse_deferred_context_payload({"schema_version": 2}) + + +def test_validate_live_comment_replay_contract_reports_changed_for_command_ambiguity(monkeypatch): + review = review_state.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( + reconcile, + "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 = reconcile._validate_live_comment_replay_contract( + FakeReviewerBotRuntime(monkeypatch), + 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): + 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"): + 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 + + +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 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)] 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..a070d521b --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_api_contract.py @@ -0,0 +1,102 @@ +import pytest + +from scripts.reviewer_bot_lib import reviews +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +from tests.fixtures.reviewer_bot_fakes 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), + ) + runtime.github.stub(routes) + + with pytest.raises(RuntimeError, match="server_error"): + reviews.list_open_items_with_status_labels(runtime) + + +def test_get_pull_request_reviews_result_paginates(monkeypatch): + runtime = FakeReviewerBotRuntime(monkeypatch) + 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}], + ) + ) + runtime.github.stub(routes) + + 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}], + ) + runtime.github.stub(routes) + runtime.get_pull_request_reviews = lambda issue_number: [{"id": 10}] + + 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"}, + ) + runtime.github.stub(routes) + + 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"}), + ) + runtime.github.stub(routes) + + 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"], + ) + runtime.github.stub(routes) + + result = reviews._pull_request_read_result(runtime, 42) + + assert result == {"ok": False, "reason": "pull_request_unavailable", "failure_kind": "invalid_payload"} 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..3aea57252 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_live_fetch.py @@ -0,0 +1,210 @@ +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.reviewer_bot import ( + accept_contributor_revision, + accept_reviewer_comment, + accept_reviewer_review, + accepted_record, + issue_snapshot, + make_state, + make_tracked_review_state, + pull_request_payload, + review_payload, +) +from tests.fixtures.reviewer_bot_fakes import RouteGitHubApi, github_result + + +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.github.stub(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") + 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")], + ) + runtime = _runtime(monkeypatch, routes) + + desired_labels, metadata = reviews.project_status_labels_for_item(runtime, 42, state) + + 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) + 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"), + ], + ) + 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 = reviews.compute_reviewer_response_state(runtime, 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_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_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: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"), + ], + ) + runtime = _runtime(monkeypatch, routes) + + 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" + + +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["reviewer_review"] = { + "accepted": { + **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": {}, + }, + "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" + 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")], + ) + 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 + 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 = 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 = reviews.project_status_labels_for_item(runtime, 42, state) + + 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, 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, + [review_payload(99, state="COMMENTED", submitted_at="2026-03-17T11:00:00Z", commit_id="head-0", author="alice")], + ) + runtime = _runtime(monkeypatch, routes) + + 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_emits_awaiting_write_approval_only_after_completion(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) + routes = RouteGitHubApi().add_pull_request_snapshot(42, pull_request_payload(42, head_sha="head-1")).add_pull_request_reviews( + 42, + [review_payload(10, state="APPROVED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="bob")], + ) + runtime = _runtime(monkeypatch, routes) + runtime.get_user_permission_status = lambda username, required_permission="triage": "denied" + + desired_labels, metadata = reviews.project_status_labels_for_item(runtime, 42, state) + + assert desired_labels == {STATUS_AWAITING_WRITE_APPROVAL_LABEL} + assert metadata["state"] == "awaiting_write_approval" + review["mandatory_approver_required"] = True + 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") + routes = RouteGitHubApi().add_request("GET", "pulls/42", result=github_result(502, {"message": "bad gateway"}, retry_attempts=1)) + runtime = _runtime(monkeypatch, routes) + + response_state = reviews.compute_reviewer_response_state(runtime, 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 = 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 = reviews.compute_reviewer_response_state(runtime, 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 = 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, + [review_payload(10, state="APPROVED", submitted_at="2026-03-17T10:01:00Z", commit_id="head-1", author="alice")], + ) + runtime = _runtime(monkeypatch, routes) + runtime.get_user_permission_status = lambda username, required_permission="triage": "unavailable" + + response_state = reviews.compute_reviewer_response_state(runtime, 42, review) + + assert response_state["state"] == "projection_failed" + assert response_state["reason"] == "live_review_state_unknown" 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..e36bee151 --- /dev/null +++ b/tests/unit/reviewer_bot/test_reviews_projection.py @@ -0,0 +1,173 @@ +import json +from types import SimpleNamespace + +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, + pull_request_payload, + review_payload, +) + + +def _bot(**overrides): + 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, + ) + 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, + 42, + reviewer="alice", + active_cycle_started_at="2026-03-17T09:00:00Z", + ) + before = json.loads(json.dumps(review)) + 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 [], + {}, + "ok", + True, + None, + 0, + None, + ) + ) + + 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(): + 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)) + 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 [ + review_payload( + 10, + state="APPROVED", + submitted_at="2026-03-17T10:01:00Z", + commit_id="head-1", + author="alice", + ) + ], + {}, + "ok", + True, + None, + 0, + None, + ) + ) + + result = reviews.compute_pr_approval_state_result(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 = make_tracked_review_state(make_state(), 42) + + 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"}, + 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": reviews.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 + + +def test_normalize_reviews_with_parsed_timestamps_is_pure(): + review_items = [ + { + "id": 10, + "state": "APPROVED", + "submitted_at": "2026-03-17T10:01:00Z", + "commit_id": "head-1", + "user": {"login": "alice"}, + } + ] + before = json.loads(json.dumps(review_items)) + + normalized = reviews_projection.normalize_reviews_with_parsed_timestamps( + review_items, + parse_timestamp=reviews.parse_github_timestamp, + ) + + 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(): + 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"] 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..41f456b07 --- /dev/null +++ b/tests/unit/reviewer_bot/test_state_store.py @@ -0,0 +1,153 @@ +from scripts.reviewer_bot_lib import state_store +from scripts.reviewer_bot_lib.config import ( + FRESHNESS_RUNTIME_EPOCH_LEGACY, + STATE_SCHEMA_VERSION, + GitHubApiResult, + StateIssueSnapshot, +) +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 _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) + + 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(monkeypatch): + observed = {} + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["retry_policy"] = kwargs.get("retry_policy") + return 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, + ) + + 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) + + 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 = {} + + def fake_request(method, endpoint, data=None, extra_headers=None, **kwargs): + observed["extra_headers"] = extra_headers + return GitHubApiResult( + status_code=200, + payload={"body": data["body"]}, + headers={}, + text="ok", + ok=True, + failure_kind=None, + retry_attempts=0, + transport_error=None, + ) + + 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(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 = _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(monkeypatch): + state = make_state() + snapshot = StateIssueSnapshot( + body="body", + etag='"etag"', + html_url="https://example.com/state/1", + ) + responses = iter( + [ + GitHubApiResult(412, {"message": "precondition failed"}, {}, "precondition failed", False, None, 0, None), + GitHubApiResult(200, {"body": "updated"}, {}, "ok", True, None, 0, None), + ] + ) + + clock = FakeClock() + sleeper = RecordingSleeper() + jitter = DeterministicJitter(0.25) + logger = RecordingLogger() + + 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() + 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(monkeypatch): + 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 = _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) + + assert snapshot is not None + assert snapshot.html_url == "https://github.com/rustfoundation/safety-critical-rust-coding-guidelines/issues/1" 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..b05686c66 --- /dev/null +++ b/tests/unit/reviewer_bot/test_sweeper_downloads.py @@ -0,0 +1,107 @@ +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 + + +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) + + runtime = FakeReviewerBotRuntime(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.stub_sequence([*responses, success_response]) + + status, artifact_payload = sweeper._download_artifact_payload( + runtime, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + 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): + 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") + runtime.get_github_token = lambda: "token" + runtime.sleeper = RecordingSleeper() + runtime.artifact_download_transport.stub_sequence([RuntimeError("timeout")]) + + status, payload = sweeper._download_artifact_payload( + runtime, + {"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): + runtime = FakeReviewerBotRuntime(monkeypatch) + runtime.set_config_value("GITHUB_TOKEN", "token") + runtime.get_github_token = lambda: "token" + runtime.sleeper = RecordingSleeper() + runtime.artifact_download_transport.stub_sequence([FakeGitHubResponse(429, {"message": "slow down"}, "slow down")] * 6) + + status, payload = sweeper._download_artifact_payload( + runtime, + {"archive_download_url": "https://example.com/artifact.zip", "expired": False}, + "deferred-comment.json", + ) + + 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) + 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 + + +def test_list_run_artifacts_consumes_retry_aware_success(monkeypatch): + 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(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 new file mode 100644 index 000000000..dd21b00b6 --- /dev/null +++ b/tests/unit/reviewer_bot/test_sweeper_logic.py @@ -0,0 +1,434 @@ +import pytest + +from scripts.reviewer_bot_lib import review_state, sweeper +from tests.fixtures.fake_runtime import FakeReviewerBotRuntime +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, + 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: 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 = 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=[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.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"), + ] + + 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 + 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, freeze_sweeper_now): + freeze_sweeper_now("2026-03-25T12:30:00Z") + runtime = _runtime(monkeypatch) + state = make_state() + 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.github.stub(routes) + runtime.get_pull_request_reviews = lambda issue_number: [] + + 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" + + +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 = 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"] + 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.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 + 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 = 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"] + 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.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 + 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 = review_state.ensure_review_entry(state, 42, create=True) + assert review is not 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.github.stub(routes) + + 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"] + + +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 = 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"} + 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.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 + 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_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 = 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["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"} + 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.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 + 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): + 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(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( + runtime, + review, + 42, + "pull_request_review:303", + pull_request_review_event(303, submitted_at="2026-03-25T11:00:00Z", state="COMMENTED", commit_id="head-1"), + ) + + 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_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", + "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): + 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", + pr_number=42, + workflow_file=".github/workflows/reviewer-bot-pr-comment-observer.yml", + workflow_runs=[ + 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] + + +def test_stage_b_artifact_correlation_rejects_ambiguous_exact_matches(): + result = sweeper.correlate_run_artifacts_exact( + { + 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, + ) + 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_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} + 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