From 41d6760c510d0ff76f5c532bb3d2aaa19815db9c Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Tue, 31 Mar 2026 01:11:16 +0900 Subject: [PATCH 1/2] feat(reviewer-bot): add reviewer board preview preflight --- .github/reviewer-bot-tests/test_main.py | 229 ++++++++++ .../reviewer-bot-tests/test_reviewer_bot.py | 228 +++++++++- scripts/reviewer_bot.py | 78 ++++ scripts/reviewer_bot_lib/app.py | 4 +- scripts/reviewer_bot_lib/config.py | 59 +++ scripts/reviewer_bot_lib/context.py | 25 ++ scripts/reviewer_bot_lib/github_api.py | 77 +++- scripts/reviewer_bot_lib/maintenance.py | 43 ++ scripts/reviewer_bot_lib/project_board.py | 400 ++++++++++++++++++ 9 files changed, 1132 insertions(+), 11 deletions(-) create mode 100644 scripts/reviewer_bot_lib/project_board.py diff --git a/.github/reviewer-bot-tests/test_main.py b/.github/reviewer-bot-tests/test_main.py index d42be02e9..65eb43232 100644 --- a/.github/reviewer-bot-tests/test_main.py +++ b/.github/reviewer-bot-tests/test_main.py @@ -62,12 +62,77 @@ def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys): 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 @@ -248,6 +313,170 @@ def fake_sync_status_labels_for_items(state, issue_numbers): ] +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) diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index a69065d18..e15b68bda 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -1,6 +1,7 @@ import json import os import subprocess +from datetime import timedelta from pathlib import Path import pytest @@ -24,6 +25,69 @@ def make_state(epoch: str = "freshness_v15"): } +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 = [ @@ -62,6 +126,7 @@ def clean_env(monkeypatch): 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): @@ -71,6 +136,18 @@ def test_load_state_sets_schema_and_epoch_defaults(monkeypatch): 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"), [ @@ -491,15 +568,21 @@ def test_check_overdue_reviews_skips_pr_with_current_head_reviewer_review(monkey 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"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at review["reviewer_review"]["accepted"] = { "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", + "timestamp": reviewer_review_at, "actor": "alice", "reviewed_head_sha": "head-1", "source_precedence": 1, @@ -507,7 +590,7 @@ def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_retu } review["contributor_comment"]["accepted"] = { "semantic_key": "issue_comment:20", - "timestamp": "2026-03-12T00:00:00Z", + "timestamp": contributor_comment_at, "actor": "bob", "reviewed_head_sha": None, "source_precedence": 0, @@ -536,15 +619,21 @@ def test_check_overdue_reviews_uses_contributor_comment_timestamp_when_turn_retu 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"] = "2026-03-01T00:00:00Z" - review["active_cycle_started_at"] = "2026-03-01T00:00:00Z" + review["assigned_at"] = assigned_at + review["active_cycle_started_at"] = assigned_at review["reviewer_review"]["accepted"] = { "semantic_key": "pull_request_review:10", - "timestamp": "2026-03-02T00:00:00Z", + "timestamp": reviewer_review_at, "actor": "alice", "reviewed_head_sha": "head-1", "source_precedence": 1, @@ -552,7 +641,7 @@ def test_check_overdue_reviews_uses_contributor_revision_timestamp_when_head_cha } review["contributor_revision"]["accepted"] = { "semantic_key": "pull_request_sync:42:head-2", - "timestamp": "2026-03-12T00:00:00Z", + "timestamp": contributor_revision_at, "actor": None, "reviewed_head_sha": "head-2", "source_precedence": 1, @@ -707,6 +796,129 @@ def test_project_status_labels_uses_live_current_reviewer_review_when_channel_st 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) diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index b6b321d48..61a54c3b5 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -73,6 +73,7 @@ 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 @@ -115,6 +116,26 @@ 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, @@ -180,6 +201,12 @@ def get_github_token() -> str: return github_api_module.get_github_token() +def get_github_graphql_token(*, prefer_board_token: bool = False) -> str: + return github_api_module.get_github_graphql_token( + _runtime_bot(), prefer_board_token=prefer_board_token + ) + + def github_api_request( method: str, endpoint: str, @@ -202,6 +229,31 @@ def github_api(method: str, endpoint: str, data: dict | None = None) -> Any | No 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 github_graphql( + query: str, + variables: dict | None = None, + *, + token: str | None = None, +) -> Any | None: + return github_api_module.github_graphql(_runtime_bot(), query, variables, token=token) + + def post_comment(issue_number: int, body: str) -> bool: return github_api_module.post_comment(_runtime_bot(), issue_number, body) @@ -684,6 +736,24 @@ 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, @@ -802,6 +872,14 @@ 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 # ============================================================================== diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 5d721b888..d93331d4b 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -75,7 +75,7 @@ def classify_event_intent(bot: ReviewerBotContext, event_name: str, event_action if event_name == "workflow_dispatch": action = os.environ.get("MANUAL_ACTION", "").strip() - if action == "show-state": + if action in {"show-state", "preview-reviewer-board"}: return bot.EVENT_INTENT_NON_MUTATING_READONLY return bot.EVENT_INTENT_MUTATING @@ -182,7 +182,7 @@ def main(bot: ReviewerBotContext): ) touched_items = bot.drain_touched_items() - if event_name in {"schedule", "workflow_dispatch"} and status_projection_repair_needed(bot, state): + if lock_required and event_name in {"schedule", "workflow_dispatch"} and status_projection_repair_needed(bot, state): touched_items = sorted( { *touched_items, diff --git a/scripts/reviewer_bot_lib/config.py b/scripts/reviewer_bot_lib/config.py index b33177148..428455d84 100644 --- a/scripts/reviewer_bot_lib/config.py +++ b/scripts/reviewer_bot_lib/config.py @@ -45,6 +45,25 @@ FRESHNESS_RUNTIME_EPOCH_LEGACY = "legacy_v14" FRESHNESS_RUNTIME_EPOCH_V18 = "freshness_v15" STATUS_PROJECTION_EPOCH = "status_projection_v2" +REVIEWER_BOARD_ENABLED_ENV = "REVIEWER_BOARD_ENABLED" +REVIEWER_BOARD_TOKEN_ENV = "REVIEWER_BOARD_TOKEN" +REVIEWER_BOARD_ORG = "rustfoundation" +REVIEWER_BOARD_PROJECT_NUMBER = 1 +REVIEWER_BOARD_FIELD_REVIEW_STATE = "Review State" +REVIEWER_BOARD_FIELD_REVIEWER = "Reviewer" +REVIEWER_BOARD_FIELD_ASSIGNED_AT = "Assigned At" +REVIEWER_BOARD_FIELD_WAITING_SINCE = "Waiting Since" +REVIEWER_BOARD_FIELD_NEEDS_ATTENTION = "Needs Attention" +REVIEWER_BOARD_OPTION_AWAITING_REVIEWER = "Awaiting Reviewer" +REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR = "Awaiting Contributor" +REVIEWER_BOARD_OPTION_AWAITING_WRITE_APPROVAL = "Awaiting Write Approval" +REVIEWER_BOARD_OPTION_DONE = "Done" +REVIEWER_BOARD_OPTION_UNASSIGNED = "Unassigned" +REVIEWER_BOARD_OPTION_ATTENTION_NO = "No" +REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT = "Warning Sent" +REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT = "Transition Notice Sent" +REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED = "Triage Approval Required" +REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED = "Projection Repair Required" REVIEW_FRESHNESS_RUNBOOK_PATH = "docs/reviewer-bot-review-freshness-operator-runbook.md" AUTHOR_ASSOCIATION_TRUST_ALLOWLIST = {"OWNER", "MEMBER", "COLLABORATOR"} DEFERRED_ARTIFACT_RETENTION_DAYS = 7 @@ -52,6 +71,46 @@ DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS = 7 * 24 * 60 * 60 DEFERRED_DISCOVERY_OVERLAP_SECONDS = 60 * 60 +REVIEWER_BOARD_PROJECT_MANIFEST = { + REVIEWER_BOARD_FIELD_REVIEW_STATE: { + "type": "single_select", + "required": True, + "options": ( + REVIEWER_BOARD_OPTION_AWAITING_REVIEWER, + REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR, + REVIEWER_BOARD_OPTION_AWAITING_WRITE_APPROVAL, + REVIEWER_BOARD_OPTION_DONE, + REVIEWER_BOARD_OPTION_UNASSIGNED, + ), + }, + REVIEWER_BOARD_FIELD_REVIEWER: { + "type": "text", + "required": True, + "options": (), + }, + REVIEWER_BOARD_FIELD_ASSIGNED_AT: { + "type": "date", + "required": True, + "options": (), + }, + REVIEWER_BOARD_FIELD_WAITING_SINCE: { + "type": "date", + "required": True, + "options": (), + }, + REVIEWER_BOARD_FIELD_NEEDS_ATTENTION: { + "type": "single_select", + "required": True, + "options": ( + REVIEWER_BOARD_OPTION_ATTENTION_NO, + REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT, + REVIEWER_BOARD_OPTION_ATTENTION_TRANSITION_NOTICE_SENT, + REVIEWER_BOARD_OPTION_ATTENTION_TRIAGE_APPROVAL_REQUIRED, + REVIEWER_BOARD_OPTION_ATTENTION_PROJECTION_REPAIR_REQUIRED, + ), + }, +} + MANDATORY_TRIAGE_APPROVER_LABEL = "triage approver required" STATUS_AWAITING_REVIEWER_RESPONSE_LABEL = "status: awaiting reviewer response" STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL = "status: awaiting contributor response" diff --git a/scripts/reviewer_bot_lib/context.py b/scripts/reviewer_bot_lib/context.py index 25983d796..2c3d69c76 100644 --- a/scripts/reviewer_bot_lib/context.py +++ b/scripts/reviewer_bot_lib/context.py @@ -20,6 +20,7 @@ class GitHubTransportContext(Protocol): GitHubApiResult: type[GitHubApiResult] def get_github_token(self) -> str: ... + def get_github_graphql_token(self, *, prefer_board_token: bool = False) -> str: ... def github_api_request( self, method: str, @@ -30,6 +31,21 @@ def github_api_request( suppress_error_log: bool = False, ) -> GitHubApiResult: ... def github_api(self, method: str, endpoint: str, data: dict | None = None) -> Any | None: ... + def github_graphql_request( + self, + query: str, + variables: dict | None = None, + *, + token: str | None = None, + suppress_error_log: bool = False, + ) -> GitHubApiResult: ... + def github_graphql( + self, + query: str, + variables: dict | None = None, + *, + token: str | None = None, + ) -> Any | None: ... def request_reviewer_assignment(self, issue_number: int, username: str) -> AssignmentAttempt: ... def remove_assignee(self, issue_number: int, username: str) -> bool: ... def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: ... @@ -154,3 +170,12 @@ def handle_manual_dispatch(self, state: dict) -> bool: ... def handle_scheduled_check(self, state: dict) -> bool: ... def handle_workflow_run_event(self, state: dict) -> bool: ... def sync_status_labels_for_items(self, state: dict, issue_numbers: Iterable[int]) -> bool: ... + 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]: ... diff --git a/scripts/reviewer_bot_lib/github_api.py b/scripts/reviewer_bot_lib/github_api.py index 895435492..2a57973e0 100644 --- a/scripts/reviewer_bot_lib/github_api.py +++ b/scripts/reviewer_bot_lib/github_api.py @@ -1,5 +1,6 @@ """GitHub transport and issue/PR mutation helpers.""" +import json import os import random import sys @@ -8,7 +9,12 @@ import requests -from .config import LOCK_API_RETRY_LIMIT, LOCK_RETRY_BASE_SECONDS, STATUS_LABEL_CONFIG +from .config import ( + LOCK_API_RETRY_LIMIT, + LOCK_RETRY_BASE_SECONDS, + REVIEWER_BOARD_TOKEN_ENV, + STATUS_LABEL_CONFIG, +) from .context import GitHubTransportContext @@ -20,6 +26,15 @@ def get_github_token() -> str: return token +def get_github_graphql_token(bot: GitHubTransportContext, *, prefer_board_token: bool = False) -> str: + if prefer_board_token: + token = os.environ.get(REVIEWER_BOARD_TOKEN_ENV) + if not token: + raise RuntimeError(f"{REVIEWER_BOARD_TOKEN_ENV} not set") + return token + return bot.get_github_token() + + def github_api_request( bot: GitHubTransportContext, method: str, @@ -73,6 +88,66 @@ def github_api(bot: GitHubTransportContext, method: str, endpoint: str, data: di return response.payload +def github_graphql_request( + bot: GitHubTransportContext, + query: str, + variables: dict | None = None, + *, + token: str | None = None, + suppress_error_log: bool = False, +): + 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: + 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, + ) + + +def github_graphql( + bot: GitHubTransportContext, + query: str, + variables: dict | None = None, + *, + token: str | None = None, +): + response = bot.github_graphql_request(query, variables, token=token) + if not response.ok: + return None + if response.payload is None: + return {} + return response.payload + + def post_comment(bot: GitHubTransportContext, issue_number: int, body: str) -> bool: return bot.github_api("POST", f"issues/{issue_number}/comments", {"body": body}) is not None diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 987a6ca99..4f7e2dd1e 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -13,6 +13,11 @@ check_overdue_reviews, handle_overdue_review_warning, ) +from .project_board import ( + format_preview_for_output, + preview_board_projection_for_item, + reviewer_board_preflight, +) from .sweeper import sweep_deferred_gaps @@ -36,6 +41,44 @@ def handle_manual_dispatch(bot, state: dict) -> bool: if action == "show-state": print(f"Current state:\n{yaml.dump(state, default_flow_style=False)}") return False + if action == "preview-reviewer-board": + preflight = reviewer_board_preflight(bot) + if not preflight.enabled: + print("Reviewer board preview skipped: reviewer board is disabled.") + return False + if not preflight.valid: + raise RuntimeError( + "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)] + else: + active_reviews = state.get("active_reviews") + if isinstance(active_reviews, dict): + candidates: set[int] = set() + for issue_key, review_data in active_reviews.items(): + if not isinstance(review_data, dict): + continue + try: + issue_number = int(issue_key) + except (TypeError, ValueError): + continue + if issue_number > 0: + candidates.add(issue_number) + issue_numbers = sorted(candidates) + + previews = [preview_board_projection_for_item(bot, state, issue_number) for issue_number in issue_numbers] + print( + yaml.safe_dump( + format_preview_for_output(preflight, previews), + default_flow_style=False, + sort_keys=False, + ).rstrip() + ) + return False bot.assert_lock_held("handle_manual_dispatch") if action == "sync-members": _, changes = bot.sync_members_with_queue(state) diff --git a/scripts/reviewer_bot_lib/project_board.py b/scripts/reviewer_bot_lib/project_board.py new file mode 100644 index 000000000..3b21aafd2 --- /dev/null +++ b/scripts/reviewer_bot_lib/project_board.py @@ -0,0 +1,400 @@ +"""Reviewer board preflight and read-only preview helpers.""" + +from __future__ import annotations + +import copy +import os +from dataclasses import asdict, dataclass +from typing import Any + +from .config import ( + 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_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, +) + +PROJECT_BOARD_METADATA_QUERY = """ +query ReviewerBoardProjectMetadata($organization: String!, $projectNumber: Int!) { + organization(login: $organization) { + projectV2(number: $projectNumber) { + id + title + fields(first: 100) { + nodes { + __typename + ... on ProjectV2FieldCommon { + id + name + } + ... on ProjectV2Field { + dataType + } + ... on ProjectV2SingleSelectField { + options { + id + name + } + } + } + } + } + } +} +""" + + +@dataclass(frozen=True) +class ProjectFieldMetadata: + field_id: str + name: str + field_type: str + option_ids: dict[str, str] + + +@dataclass(frozen=True) +class ProjectMetadata: + project_id: str + project_title: str | None + fields_by_name: dict[str, ProjectFieldMetadata] + + +@dataclass(frozen=True) +class ProjectBoardPreflight: + enabled: bool + configured: bool + valid: bool + project_id: str | None + errors: tuple[str, ...] + + +@dataclass(frozen=True) +class ReviewStateDerivation: + state: str + anchor_timestamp: str | None + reason: str | None + + +@dataclass(frozen=True) +class BoardProjectionInput: + issue_number: int + classification: str + review_state_derivation: ReviewStateDerivation | None + review_data_snapshot: dict[str, Any] | None + repair_targets: frozenset[str] + live_item_closed: bool + + +@dataclass(frozen=True) +class BoardProjectionValues: + review_state: str | None + reviewer: str | None + assigned_at: str | None + waiting_since: str | None + needs_attention: str | None + archive: bool = False + ensure_membership: bool = False + + +@dataclass(frozen=True) +class BoardPreviewResult: + issue_number: int + eligible: bool + classification: str + desired: BoardProjectionValues | None + 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 _field_type_name(field_node: dict[str, Any]) -> str: + typename = str(field_node.get("__typename", "")) + if typename == "ProjectV2SingleSelectField": + return "single_select" + if typename == "ProjectV2Field": + data_type = str(field_node.get("dataType", "")).upper() + if data_type == "TEXT": + return "text" + if data_type == "DATE": + return "date" + return typename.lower() + + +def resolve_project_metadata(bot) -> ProjectMetadata: + cached = getattr(bot, "_reviewer_board_project_metadata", None) + if isinstance(cached, ProjectMetadata): + return cached + + response = bot.github_graphql( + PROJECT_BOARD_METADATA_QUERY, + { + "organization": REVIEWER_BOARD_ORG, + "projectNumber": REVIEWER_BOARD_PROJECT_NUMBER, + }, + token=bot.get_github_graphql_token(prefer_board_token=True), + ) + organization = (response or {}).get("data", {}).get("organization") if isinstance(response, dict) else None + project = organization.get("projectV2") if isinstance(organization, dict) else None + if not isinstance(project, dict): + raise RuntimeError( + f"Unable to resolve reviewer board project {REVIEWER_BOARD_ORG}/{REVIEWER_BOARD_PROJECT_NUMBER}" + ) + + fields_by_name: dict[str, ProjectFieldMetadata] = {} + nodes = ((project.get("fields") or {}).get("nodes") or []) if isinstance(project.get("fields"), dict) else [] + for node in nodes: + if not isinstance(node, dict): + continue + field_id = node.get("id") + name = node.get("name") + if not isinstance(field_id, str) or not isinstance(name, str): + continue + options = node.get("options") if isinstance(node.get("options"), list) else [] + option_ids = {} + for option in options: + if not isinstance(option, dict): + continue + option_name = option.get("name") + option_id = option.get("id") + if isinstance(option_name, str) and isinstance(option_id, str): + option_ids[option_name] = option_id + fields_by_name[name] = ProjectFieldMetadata( + field_id=field_id, + name=name, + field_type=_field_type_name(node), + option_ids=option_ids, + ) + + metadata = ProjectMetadata( + project_id=str(project.get("id", "")), + project_title=project.get("title") if isinstance(project.get("title"), str) else None, + fields_by_name=fields_by_name, + ) + setattr(bot, "_reviewer_board_project_metadata", metadata) + return metadata + + +def validate_project_manifest(bot, metadata: ProjectMetadata) -> tuple[str, ...]: + del bot + errors: list[str] = [] + for field_name, expected in REVIEWER_BOARD_PROJECT_MANIFEST.items(): + field = metadata.fields_by_name.get(field_name) + if field is None: + errors.append(f"Missing reviewer board field: {field_name}") + continue + if field.field_type != expected["type"]: + errors.append( + f"Field {field_name} has type {field.field_type}; expected {expected['type']}" + ) + for option_name in expected.get("options", ()): + if option_name not in field.option_ids: + errors.append(f"Field {field_name} is missing option: {option_name}") + return tuple(errors) + + +def reviewer_board_preflight(bot) -> ProjectBoardPreflight: + if not reviewer_board_enabled(bot): + return ProjectBoardPreflight( + enabled=False, + configured=True, + valid=True, + project_id=None, + errors=(), + ) + + errors: list[str] = [] + project_id: str | None = None + try: + bot.get_github_graphql_token(prefer_board_token=True) + except RuntimeError as exc: + errors.append(str(exc)) + + metadata = None + if not errors: + metadata = resolve_project_metadata(bot) + project_id = metadata.project_id or None + errors.extend(validate_project_manifest(bot, metadata)) + + return ProjectBoardPreflight( + enabled=True, + configured=not errors, + valid=not errors, + project_id=project_id, + errors=tuple(errors), + ) + + +def _classify_item(issue_snapshot: dict[str, Any] | None, review_data: dict[str, Any] | None) -> str: + if isinstance(issue_snapshot, dict) and str(issue_snapshot.get("state", "")).lower() == "closed": + return "closed" + if not isinstance(review_data, dict): + return "open_untracked" + current_reviewer = review_data.get("current_reviewer") + if not isinstance(current_reviewer, str) or not current_reviewer.strip(): + return "open_tracked_unassigned" + return "open_tracked_assigned" + + +def _format_date(value: Any) -> str | None: + if not isinstance(value, str) or not value: + return None + return value[:10] + + +def _derive_review_state(bot, issue_number: int, review_data_snapshot: dict[str, Any], issue_snapshot: dict[str, Any]) -> ReviewStateDerivation: + preview_review_data = copy.deepcopy(review_data_snapshot) + derived = bot.compute_reviewer_response_state( + issue_number, + preview_review_data, + issue_snapshot=copy.deepcopy(issue_snapshot), + ) + state = str(derived.get("state", "projection_failed")) + return ReviewStateDerivation( + state=state, + anchor_timestamp=derived.get("anchor_timestamp") if isinstance(derived.get("anchor_timestamp"), str) else None, + reason=derived.get("reason") if isinstance(derived.get("reason"), str) else None, + ) + + +def build_board_projection_input(bot, state: dict, issue_number: int, *, issue_snapshot: dict[str, Any] | None = None) -> BoardProjectionInput: + if issue_snapshot is None: + issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) + if not isinstance(issue_snapshot, dict): + raise RuntimeError(f"Unable to load issue or PR snapshot for #{issue_number}") + + active_reviews = state.get("active_reviews") + review_data = active_reviews.get(str(issue_number)) if isinstance(active_reviews, dict) else None + review_data_snapshot = copy.deepcopy(review_data) if isinstance(review_data, dict) else None + classification = _classify_item(issue_snapshot, review_data_snapshot) + review_state_derivation = None + if classification == "open_tracked_assigned" and review_data_snapshot is not None: + review_state_derivation = _derive_review_state(bot, issue_number, review_data_snapshot, issue_snapshot) + + return BoardProjectionInput( + issue_number=issue_number, + classification=classification, + review_state_derivation=review_state_derivation, + review_data_snapshot=review_data_snapshot, + repair_targets=frozenset(), + live_item_closed=classification == "closed", + ) + + +def derive_board_projection(input: BoardProjectionInput) -> BoardProjectionValues | None: + if input.classification in {"closed", "open_untracked"}: + return BoardProjectionValues( + review_state=None, + reviewer=None, + assigned_at=None, + waiting_since=None, + needs_attention=None, + archive=True, + ensure_membership=False, + ) + + review_data = input.review_data_snapshot or {} + if input.classification == "open_tracked_unassigned": + return BoardProjectionValues( + review_state=REVIEWER_BOARD_OPTION_UNASSIGNED, + reviewer=None, + assigned_at=None, + waiting_since=None, + needs_attention=REVIEWER_BOARD_OPTION_ATTENTION_NO, + ensure_membership=True, + ) + + derivation = input.review_state_derivation + if derivation is None: + raise RuntimeError(f"Board derivation unavailable for #{input.issue_number}") + if derivation.state == "projection_failed": + raise RuntimeError( + f"Board derivation failed for #{input.issue_number}: {derivation.reason or 'unknown reason'}" + ) + + review_state_map = { + "awaiting_reviewer_response": REVIEWER_BOARD_OPTION_AWAITING_REVIEWER, + "awaiting_contributor_response": REVIEWER_BOARD_OPTION_AWAITING_CONTRIBUTOR, + "awaiting_write_approval": REVIEWER_BOARD_OPTION_AWAITING_WRITE_APPROVAL, + "done": REVIEWER_BOARD_OPTION_DONE, + } + review_state = review_state_map.get(derivation.state) + if review_state is None: + 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"): + 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 + elif review_data.get("transition_warning_sent"): + needs_attention = REVIEWER_BOARD_OPTION_ATTENTION_WARNING_SENT + + return BoardProjectionValues( + review_state=review_state, + reviewer=review_data.get("current_reviewer") if isinstance(review_data.get("current_reviewer"), str) and review_data.get("current_reviewer").strip() else None, + assigned_at=_format_date(review_data.get("assigned_at")), + waiting_since=_format_date(derivation.anchor_timestamp), + needs_attention=needs_attention, + ensure_membership=True, + ) + + +def preview_board_projection_for_item(bot, state: dict, issue_number: int) -> BoardPreviewResult: + issue_snapshot = bot.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( + issue_number=issue_number, + eligible=input.classification in {"open_tracked_assigned", "open_tracked_unassigned"}, + classification=input.classification, + desired=desired, + noop_reason=None, + ) + + +def format_preview_for_output(preflight: ProjectBoardPreflight, previews: list[BoardPreviewResult]) -> list[dict[str, Any]]: + rendered = [ + { + "enabled": preflight.enabled, + "configured": preflight.configured, + "valid": preflight.valid, + "project_id": preflight.project_id, + "required_fields": [ + REVIEWER_BOARD_FIELD_REVIEW_STATE, + REVIEWER_BOARD_FIELD_REVIEWER, + REVIEWER_BOARD_FIELD_ASSIGNED_AT, + REVIEWER_BOARD_FIELD_WAITING_SINCE, + REVIEWER_BOARD_FIELD_NEEDS_ATTENTION, + ], + } + ] + for preview in previews: + preview_dict = asdict(preview) + desired = preview_dict.get("desired") + if desired is None: + rendered.append(preview_dict) + continue + preview_dict["desired"] = { + key: value + for key, value in desired.items() + if value is not None or key in {"archive", "ensure_membership"} + } + rendered.append(preview_dict) + return rendered From 867ed7492bf6e52a9f8aa365493c0229e52c8819 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Tue, 31 Mar 2026 02:20:38 +0900 Subject: [PATCH 2/2] feat(reviewer-bot): expose reviewer board preview workflow --- .../reviewer-bot-tests/test_reviewer_bot.py | 24 +++++++++++++++++++ .../workflows/reviewer-bot-sweeper-repair.yml | 9 ++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index e15b68bda..28accb930 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -2584,6 +2584,30 @@ def test_workflow_policy_split_and_lock_only_boundaries(): 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 diff --git a/.github/workflows/reviewer-bot-sweeper-repair.yml b/.github/workflows/reviewer-bot-sweeper-repair.yml index 875f2079d..37febf673 100644 --- a/.github/workflows/reviewer-bot-sweeper-repair.yml +++ b/.github/workflows/reviewer-bot-sweeper-repair.yml @@ -10,7 +10,11 @@ on: required: true default: check-overdue type: choice - options: [sync-members, show-state, check-overdue, repair-review-status-labels] + options: [sync-members, show-state, check-overdue, repair-review-status-labels, preview-reviewer-board] + issue_number: + description: Optional issue or PR number for targeted reviewer-board preview + required: false + type: string permissions: contents: read @@ -58,6 +62,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ github.event.action }} MANUAL_ACTION: ${{ github.event.inputs.action }} + ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -66,6 +71,8 @@ jobs: WORKFLOW_NAME: ${{ github.workflow }} WORKFLOW_JOB_NAME: ${{ github.job }} CURRENT_WORKFLOW_FILE: .github/workflows/reviewer-bot-sweeper-repair.yml + REVIEWER_BOARD_ENABLED: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && 'true' || 'false' }} + REVIEWER_BOARD_TOKEN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'preview-reviewer-board' && secrets.REVIEWER_BOARD_TOKEN || '' }} run: uv run --project "$BOT_SRC_ROOT" reviewer-bot - name: Workflow summary run: |