diff --git a/.github/reviewer-bot-tests/test_main.py b/.github/reviewer-bot-tests/test_main.py index 2c8fa1b77..f4359c3e7 100644 --- a/.github/reviewer-bot-tests/test_main.py +++ b/.github/reviewer-bot-tests/test_main.py @@ -310,6 +310,56 @@ def fake_api(method, endpoint, data=None): 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_mutating_event_fails_closed_when_state_unavailable(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") diff --git a/.github/reviewer-bot-tests/test_reviewer_bot.py b/.github/reviewer-bot-tests/test_reviewer_bot.py index 481b59df3..bac0e5f0c 100644 --- a/.github/reviewer-bot-tests/test_reviewer_bot.py +++ b/.github/reviewer-bot-tests/test_reviewer_bot.py @@ -319,7 +319,7 @@ def test_pr_comment_direct_path_is_epoch_gated(monkeypatch): assert reviewer_bot.handle_comment_event(state) is False -def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(): +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 @@ -328,6 +328,12 @@ def test_check_overdue_reviews_skips_transition_after_transition_notice_sent(): 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) == [] @@ -449,6 +455,167 @@ def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch): 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): + 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_comment"]["accepted"] = { + "semantic_key": "issue_comment:20", + "timestamp": "2026-03-12T00:00:00Z", + "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): + 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_sync:42:head-2", + "timestamp": "2026-03-12T00:00:00Z", + "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) diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index 8b8ba1d1a..af6d018f0 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -100,8 +100,10 @@ def handle_scheduled_check(bot, state: dict) -> bool: continue if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data): changed = True + bot.collect_touched_item(issue_number) if maybe_record_head_observation_repair(bot, issue_number, review_data): changed = True + bot.collect_touched_item(issue_number) overdue_reviews = check_overdue_reviews(bot, state) if not overdue_reviews: return changed diff --git a/scripts/reviewer_bot_lib/overdue.py b/scripts/reviewer_bot_lib/overdue.py index eda052c08..4e2ecd0d3 100644 --- a/scripts/reviewer_bot_lib/overdue.py +++ b/scripts/reviewer_bot_lib/overdue.py @@ -22,9 +22,23 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: if not current_reviewer: continue - last_activity = review_data.get("last_reviewer_activity") - if not last_activity: - last_activity = review_data.get("assigned_at") + 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_number, + review_data, + issue_snapshot=issue_snapshot, + ) + if response_state.get("state") != "awaiting_reviewer_response": + continue + last_activity = response_state.get("anchor_timestamp") + else: + last_activity = review_data.get("last_reviewer_activity") + if not last_activity: + last_activity = review_data.get("assigned_at") + if not last_activity: continue @@ -52,7 +66,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: if days_since_warning >= bot.TRANSITION_PERIOD_DAYS: overdue.append( { - "issue_number": int(issue_key), + "issue_number": issue_number, "reviewer": current_reviewer, "days_overdue": days_since_activity, "days_since_warning": days_since_warning, @@ -65,7 +79,7 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]: else: overdue.append( { - "issue_number": int(issue_key), + "issue_number": issue_number, "reviewer": current_reviewer, "days_overdue": days_since_activity - bot.REVIEW_DEADLINE_DAYS, "days_since_warning": 0, diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 0204f43e7..0a9c67b99 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -624,77 +624,196 @@ def _compare_cross_channel_conversation(contributor: dict | None, reviewer: dict return -1 -def project_status_labels_for_item( +def _initial_reviewer_anchor(review_data: dict) -> str | None: + for field in ("active_cycle_started_at", "cycle_started_at", "assigned_at"): + value = review_data.get(field) + if isinstance(value, str) and value: + return value + return None + + +def _contributor_revision_handoff_record(review_data: dict, current_head: str | None, reviewer_review: dict | None) -> dict | None: + contributor_revision = review_data.get("contributor_revision", {}).get("accepted") + if not isinstance(contributor_revision, dict): + return None + revision_head = contributor_revision.get("reviewed_head_sha") + if not isinstance(revision_head, str) or not isinstance(current_head, str): + return None + if revision_head != current_head: + return None + reviewer_head = reviewer_review.get("reviewed_head_sha") if isinstance(reviewer_review, dict) else None + if isinstance(reviewer_head, str) and reviewer_head == current_head: + return None + return contributor_revision + + +def compute_reviewer_response_state( bot, issue_number: int, - state: dict, + review_data: dict, *, issue_snapshot: dict | None = None, -) -> tuple[set[str] | None, dict[str, str | None]]: + pull_request: dict | None = None, + reviews: list[dict] | None = None, +) -> dict[str, object]: if issue_snapshot is None: issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number) if not isinstance(issue_snapshot, dict): - return None, {"state": "projection_failed", "reason": "issue_snapshot_unavailable"} + return {"state": "projection_failed", "reason": "issue_snapshot_unavailable"} is_pr = isinstance(issue_snapshot.get("pull_request"), dict) - if str(issue_snapshot.get("state", "")).lower() == "closed": - return set(), {"state": "closed", "reason": None} - - review_data = bot.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") if not isinstance(current_reviewer, str) or not current_reviewer.strip(): - return set(), {"state": "untracked", "reason": "no_current_reviewer"} + return {"state": "untracked", "reason": "no_current_reviewer"} reviewer_comment = review_data.get("reviewer_comment", {}).get("accepted") reviewer_review = review_data.get("reviewer_review", {}).get("accepted") contributor_comment = review_data.get("contributor_comment", {}).get("accepted") if not reviewer_comment and not reviewer_review and is_pr: - live_review = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data) + live_review = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data, reviews=reviews) if live_review is not None: reviewer_review = build_reviewer_review_record_from_live_review(live_review, actor=current_reviewer) if not reviewer_comment and not reviewer_review: - return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "no_reviewer_activity"}) - - if is_pr: - pull_request = bot.github_api("GET", f"pulls/{issue_number}") - if not isinstance(pull_request, dict): - return None, {"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 None, {"state": "projection_failed", "reason": "pull_request_head_unavailable"} - review_data["active_head_sha"] = current_head - latest_review_head = None - if isinstance(reviewer_review, dict): - latest_review_head = reviewer_review.get("reviewed_head_sha") - if not isinstance(latest_review_head, str) or latest_review_head != current_head: - return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "review_head_stale"}) + return { + "state": "awaiting_reviewer_response", + "reason": "no_reviewer_activity", + "anchor_timestamp": _initial_reviewer_anchor(review_data), + "reviewer_comment": reviewer_comment, + "reviewer_review": reviewer_review, + "contributor_comment": contributor_comment, + "contributor_handoff": None, + } latest_reviewer_response = reviewer_comment if _compare_records(reviewer_review, latest_reviewer_response) > 0: latest_reviewer_response = reviewer_review - if _compare_cross_channel_conversation(contributor_comment, latest_reviewer_response) > 0: - return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "contributor_comment_newer"}) - - if is_pr: - completion, write_approval = rebuild_pr_approval_state(bot, issue_number, review_data) - if completion is None or write_approval is None: - return None, {"state": "projection_failed", "reason": "live_review_state_unknown"} - if not completion.get("completed"): - return ({STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL}, {"state": "awaiting_contributor_response", "reason": "completion_missing"}) - if not write_approval.get("has_write_approval"): - return ({STATUS_AWAITING_WRITE_APPROVAL_LABEL}, {"state": "awaiting_write_approval", "reason": "write_approval_missing"}) - return set(), {"state": "done", "reason": "write_approval_present"} - - completion = review_data.get("current_cycle_completion") - if not isinstance(completion, dict) or not completion.get("completed"): - if review_data.get("review_completed_at"): - return set(), {"state": "done", "reason": None} - return ({STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL}, {"state": "awaiting_contributor_response", "reason": "completion_missing"}) - return set(), {"state": "done", "reason": None} + + if not is_pr: + completion = review_data.get("current_cycle_completion") + if not isinstance(completion, dict) or not completion.get("completed"): + if review_data.get("review_completed_at"): + return {"state": "done", "reason": None} + return { + "state": "awaiting_contributor_response", + "reason": "completion_missing", + "anchor_timestamp": latest_reviewer_response.get("timestamp") if isinstance(latest_reviewer_response, dict) else None, + } + return {"state": "done", "reason": 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 + + contributor_handoff = contributor_comment + contributor_revision = _contributor_revision_handoff_record(review_data, current_head, reviewer_review if isinstance(reviewer_review, dict) else None) + 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: + 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), + "current_head_sha": current_head, + "reviewer_comment": reviewer_comment, + "reviewer_review": reviewer_review, + "contributor_comment": contributor_comment, + "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" + return { + "state": "awaiting_reviewer_response", + "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, + "contributor_comment": contributor_comment, + "contributor_handoff": contributor_handoff, + } + + completion, write_approval = rebuild_pr_approval_state(bot, issue_number, review_data, pull_request=pull_request, reviews=reviews) + if completion is None or write_approval is None: + return {"state": "projection_failed", "reason": "live_review_state_unknown"} + if not completion.get("completed"): + return { + "state": "awaiting_contributor_response", + "reason": "completion_missing", + "anchor_timestamp": latest_reviewer_response.get("timestamp") if isinstance(latest_reviewer_response, dict) else None, + "current_head_sha": current_head, + "reviewer_comment": reviewer_comment, + "reviewer_review": reviewer_review, + "contributor_comment": contributor_comment, + "contributor_handoff": contributor_handoff, + } + if not write_approval.get("has_write_approval"): + return { + "state": "awaiting_write_approval", + "reason": "write_approval_missing", + "anchor_timestamp": latest_reviewer_response.get("timestamp") if isinstance(latest_reviewer_response, dict) else None, + "current_head_sha": current_head, + "reviewer_comment": reviewer_comment, + "reviewer_review": reviewer_review, + "contributor_comment": contributor_comment, + "contributor_handoff": contributor_handoff, + } + return { + "state": "done", + "reason": "write_approval_present", + "anchor_timestamp": latest_reviewer_response.get("timestamp") if isinstance(latest_reviewer_response, dict) else None, + "current_head_sha": current_head, + "reviewer_comment": reviewer_comment, + "reviewer_review": reviewer_review, + "contributor_comment": contributor_comment, + "contributor_handoff": contributor_handoff, + } + + +def project_status_labels_for_item( + bot, + issue_number: int, + state: dict, + *, + 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) + 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) + if review_data is None: + return set(), {"state": "untracked", "reason": "no_review_entry"} + current_reviewer = review_data.get("current_reviewer") + if not isinstance(current_reviewer, str) or not current_reviewer.strip(): + return set(), {"state": "untracked", "reason": "no_current_reviewer"} + + 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)} def sync_status_labels(bot, issue_number: int, desired_labels: set[str], actual_labels: Iterable[str]) -> bool: