Skip to content

Commit 2bd49bd

Browse files
authored
fix(reviewer-bot): correct reviewer response labels (#541)
1 parent f30bfab commit 2bd49bd

File tree

6 files changed

+272
-37
lines changed

6 files changed

+272
-37
lines changed

.github/reviewer-bot-tests/test_main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ def test_main_schedule_backfills_existing_transition_notice_without_duplicate_co
292292
monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current: (current, []))
293293
monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, current: False)
294294
monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False)
295+
monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [])
295296
monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []})
296297
monkeypatch.setattr(reviewer_bot, "save_state", lambda current: True)
297298
monkeypatch.setattr(reviewer_bot, "sync_status_labels_for_items", lambda current, issue_numbers: True)

.github/reviewer-bot-tests/test_reviewer_bot.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ def test_scheduled_check_backfills_transition_notice_without_reposting(monkeypat
396396
review["transition_warning_sent"] = "2026-03-10T00:00:00Z"
397397
monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False)
398398
monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False)
399+
monkeypatch.setattr(reviewer_bot, "get_pull_request_reviews", lambda issue_number: [])
399400
monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}})
400401
posted = []
401402
monkeypatch.setattr(reviewer_bot, "post_comment", lambda issue_number, body: posted.append(body) or True)
@@ -418,6 +419,36 @@ def fake_api(method, endpoint, data=None):
418419
assert posted == []
419420

420421

422+
def test_scheduled_check_repairs_missing_reviewer_review_state(monkeypatch):
423+
state = make_state()
424+
review = reviewer_bot.ensure_review_entry(state, 42, create=True)
425+
assert review is not None
426+
review["current_reviewer"] = "alice"
427+
review["active_cycle_started_at"] = "2026-03-17T09:00:00Z"
428+
monkeypatch.setattr(reviewer_bot.maintenance_module, "sweep_deferred_gaps", lambda bot, state: False)
429+
monkeypatch.setattr(reviewer_bot.maintenance_module, "maybe_record_head_observation_repair", lambda bot, issue_number, review_data: False)
430+
monkeypatch.setattr(reviewer_bot.maintenance_module, "check_overdue_reviews", lambda bot, state: [])
431+
monkeypatch.setattr(reviewer_bot, "get_issue_or_pr_snapshot", lambda issue_number: {"pull_request": {}})
432+
monkeypatch.setattr(
433+
reviewer_bot,
434+
"get_pull_request_reviews",
435+
lambda issue_number: [
436+
{
437+
"id": 10,
438+
"state": "COMMENTED",
439+
"submitted_at": "2026-03-17T10:01:00Z",
440+
"commit_id": "head-1",
441+
"user": {"login": "alice"},
442+
}
443+
],
444+
)
445+
assert reviewer_bot.handle_scheduled_check(state) is True
446+
accepted = review["reviewer_review"]["accepted"]
447+
assert accepted is not None
448+
assert accepted["semantic_key"] == "pull_request_review:10"
449+
assert review["last_reviewer_activity"] == "2026-03-17T10:01:00Z"
450+
451+
421452
def test_issue_edit_by_author_records_contributor_freshness(monkeypatch):
422453
state = make_state()
423454
review = reviewer_bot.ensure_review_entry(state, 42, create=True)
@@ -474,6 +505,127 @@ def test_project_status_labels_uses_commit_id_and_comment_freshness(monkeypatch)
474505
assert metadata["reason"] == "review_head_stale"
475506

476507

508+
def test_project_status_labels_uses_live_current_reviewer_review_when_channel_state_missing(monkeypatch):
509+
state = make_state()
510+
review = reviewer_bot.ensure_review_entry(state, 42, create=True)
511+
assert review is not None
512+
review["current_reviewer"] = "alice"
513+
review["active_cycle_started_at"] = "2026-03-17T09:00:00Z"
514+
monkeypatch.setattr(
515+
reviewer_bot,
516+
"get_issue_or_pr_snapshot",
517+
lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []},
518+
)
519+
monkeypatch.setattr(
520+
reviewer_bot,
521+
"github_api",
522+
lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None,
523+
)
524+
monkeypatch.setattr(
525+
reviewer_bot,
526+
"get_pull_request_reviews",
527+
lambda issue_number: [
528+
{
529+
"id": 10,
530+
"state": "COMMENTED",
531+
"submitted_at": "2026-03-17T10:01:00Z",
532+
"commit_id": "head-1",
533+
"user": {"login": "alice"},
534+
}
535+
],
536+
)
537+
desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state)
538+
assert desired_labels == {reviewer_bot.STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL}
539+
assert metadata["reason"] == "completion_missing"
540+
541+
542+
def test_project_status_labels_uses_live_review_fallback_for_stale_head(monkeypatch):
543+
state = make_state()
544+
review = reviewer_bot.ensure_review_entry(state, 42, create=True)
545+
assert review is not None
546+
review["current_reviewer"] = "alice"
547+
review["active_cycle_started_at"] = "2026-03-17T09:00:00Z"
548+
monkeypatch.setattr(
549+
reviewer_bot,
550+
"get_issue_or_pr_snapshot",
551+
lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []},
552+
)
553+
monkeypatch.setattr(
554+
reviewer_bot,
555+
"github_api",
556+
lambda method, endpoint, data=None: {"head": {"sha": "head-2"}} if endpoint == "pulls/42" else None,
557+
)
558+
monkeypatch.setattr(
559+
reviewer_bot,
560+
"get_pull_request_reviews",
561+
lambda issue_number: [
562+
{
563+
"id": 10,
564+
"state": "COMMENTED",
565+
"submitted_at": "2026-03-17T10:01:00Z",
566+
"commit_id": "head-1",
567+
"user": {"login": "alice"},
568+
}
569+
],
570+
)
571+
desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state)
572+
assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}
573+
assert metadata["reason"] == "review_head_stale"
574+
575+
576+
def test_project_status_labels_prefers_newer_contributor_comment_over_live_review_fallback(monkeypatch):
577+
state = make_state()
578+
review = reviewer_bot.ensure_review_entry(state, 42, create=True)
579+
assert review is not None
580+
review["current_reviewer"] = "alice"
581+
review["active_cycle_started_at"] = "2026-03-17T09:00:00Z"
582+
reviewer_bot.reviews_module.accept_channel_event(
583+
review,
584+
"contributor_comment",
585+
semantic_key="issue_comment:20",
586+
timestamp="2026-03-17T10:05:00Z",
587+
actor="bob",
588+
)
589+
monkeypatch.setattr(
590+
reviewer_bot,
591+
"get_issue_or_pr_snapshot",
592+
lambda issue_number: {"number": issue_number, "state": "open", "pull_request": {}, "labels": []},
593+
)
594+
monkeypatch.setattr(
595+
reviewer_bot,
596+
"github_api",
597+
lambda method, endpoint, data=None: {"head": {"sha": "head-1"}} if endpoint == "pulls/42" else None,
598+
)
599+
monkeypatch.setattr(
600+
reviewer_bot,
601+
"get_pull_request_reviews",
602+
lambda issue_number: [
603+
{
604+
"id": 10,
605+
"state": "COMMENTED",
606+
"submitted_at": "2026-03-17T10:01:00Z",
607+
"commit_id": "head-1",
608+
"user": {"login": "alice"},
609+
}
610+
],
611+
)
612+
desired_labels, metadata = reviewer_bot.project_status_labels_for_item(42, state)
613+
assert desired_labels == {reviewer_bot.STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}
614+
assert metadata["reason"] == "contributor_comment_newer"
615+
616+
617+
def test_record_reviewer_activity_does_not_regress_timestamp_on_legacy_backfill():
618+
review = reviewer_bot.ensure_review_entry(make_state(), 42, create=True)
619+
assert review is not None
620+
review["last_reviewer_activity"] = "2026-03-20T10:00:00Z"
621+
review["transition_warning_sent"] = "2026-03-21T10:00:00Z"
622+
review["transition_notice_sent_at"] = "2026-03-22T10:00:00Z"
623+
reviewer_bot.reviews_module.record_reviewer_activity(review, "2026-03-18T10:00:00Z")
624+
assert review["last_reviewer_activity"] == "2026-03-20T10:00:00Z"
625+
assert review["transition_warning_sent"] is None
626+
assert review["transition_notice_sent_at"] is None
627+
628+
477629
def test_project_status_labels_emits_awaiting_write_approval_only_after_completion(monkeypatch):
478630
state = make_state()
479631
review = reviewer_bot.ensure_review_entry(state, 42, create=True)

scripts/reviewer_bot_lib/maintenance.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ def handle_scheduled_check(bot, state: dict) -> bool:
9898
issue_snapshot = bot.get_issue_or_pr_snapshot(issue_number)
9999
if not isinstance(issue_snapshot, dict) or not isinstance(issue_snapshot.get("pull_request"), dict):
100100
continue
101+
if bot.reviews_module.repair_missing_reviewer_review_state(bot, issue_number, review_data):
102+
changed = True
101103
if maybe_record_head_observation_repair(bot, issue_number, review_data):
102104
changed = True
103105
overdue_reviews = check_overdue_reviews(bot, state)

scripts/reviewer_bot_lib/reconcile.py

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
_record_conversation_freshness,
1212
classify_comment_payload,
1313
)
14-
from .reviews import find_triage_approval_after, get_latest_review_by_reviewer
14+
from .reviews import (
15+
accept_reviewer_review_from_live_review,
16+
find_triage_approval_after,
17+
get_latest_valid_current_reviewer_review_for_cycle,
18+
)
1519

1620

1721
def _now_iso(bot) -> str:
@@ -53,20 +57,10 @@ def _record_review_rebuild(bot, state: dict, issue_number: int, review_data: dic
5357
completion, _ = bot.reviews_module.rebuild_pr_approval_state(bot, issue_number, review_data, pull_request=pull_request, reviews=reviews)
5458
if completion is None:
5559
raise RuntimeError(f"Unable to rebuild approval state for PR #{issue_number}")
56-
latest = get_latest_review_by_reviewer(bot, reviews, str(review_data.get("current_reviewer", "")))
60+
latest = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data, reviews=reviews)
5761
if latest is not None:
58-
commit_id = latest.get("commit_id")
5962
submitted_at = latest.get("submitted_at")
60-
if isinstance(commit_id, str) and isinstance(submitted_at, str):
61-
bot.reviews_module.accept_channel_event(
62-
review_data,
63-
"reviewer_review",
64-
semantic_key=f"pull_request_review:{latest.get('id')}",
65-
timestamp=submitted_at,
66-
actor=review_data.get("current_reviewer"),
67-
reviewed_head_sha=commit_id,
68-
source_precedence=1,
69-
)
63+
if accept_reviewer_review_from_live_review(review_data, latest, actor=review_data.get("current_reviewer")) and isinstance(submitted_at, str):
7064
bot.reviews_module.record_reviewer_activity(review_data, submitted_at)
7165
return bool(completion.get("completed"))
7266

@@ -93,24 +87,14 @@ def reconcile_active_review_entry(
9387
reviews = bot.get_pull_request_reviews(issue_number)
9488
if reviews is None:
9589
return f"❌ Failed to fetch reviews for PR #{issue_number}; cannot run `/rectify`.", False, False
96-
latest_review = get_latest_review_by_reviewer(bot, reviews, assigned_reviewer)
90+
latest_review = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data, reviews=reviews)
9791
messages: list[str] = []
9892
if latest_review is not None:
9993
latest_state = str(latest_review.get("state", "")).upper()
100-
commit_id = latest_review.get("commit_id")
10194
submitted_at = latest_review.get("submitted_at")
102-
if latest_state in {"APPROVED", "COMMENTED", "CHANGES_REQUESTED"} and isinstance(commit_id, str) and isinstance(submitted_at, str):
103-
state_changed = bot.reviews_module.accept_channel_event(
104-
review_data,
105-
"reviewer_review",
106-
semantic_key=f"pull_request_review:{latest_review.get('id')}",
107-
timestamp=submitted_at,
108-
actor=assigned_reviewer,
109-
reviewed_head_sha=commit_id,
110-
source_precedence=1,
111-
) or state_changed
112-
bot.reviews_module.record_reviewer_activity(review_data, submitted_at)
95+
if accept_reviewer_review_from_live_review(review_data, latest_review, actor=assigned_reviewer) and isinstance(submitted_at, str):
11396
state_changed = True
97+
bot.reviews_module.record_reviewer_activity(review_data, submitted_at)
11498
messages.append(f"latest review by @{assigned_reviewer} is `{latest_state}`")
11599
if _record_review_rebuild(bot, state, issue_number, review_data):
116100
state_changed = True

scripts/reviewer_bot_lib/reviews.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,99 @@ def get_latest_review_by_reviewer(bot, reviews: list[dict], reviewer: str) -> di
4848
return latest_review
4949

5050

51+
def get_latest_valid_current_reviewer_review_for_cycle(
52+
bot,
53+
issue_number: int,
54+
review_data: dict,
55+
*,
56+
reviews: list[dict] | None = None,
57+
) -> dict | None:
58+
current_reviewer = review_data.get("current_reviewer")
59+
if not isinstance(current_reviewer, str) or not current_reviewer.strip():
60+
return None
61+
boundary = get_current_cycle_boundary(bot, review_data)
62+
if boundary is None:
63+
return None
64+
if reviews is None:
65+
reviews = bot.get_pull_request_reviews(issue_number)
66+
if reviews is None:
67+
return None
68+
latest_review = None
69+
latest_key = (datetime.min.replace(tzinfo=timezone.utc), "")
70+
for review in reviews:
71+
if not isinstance(review, dict):
72+
continue
73+
author = review.get("user", {}).get("login")
74+
if not isinstance(author, str) or author.lower() != current_reviewer.lower():
75+
continue
76+
state = str(review.get("state", "")).upper()
77+
if state not in {"APPROVED", "COMMENTED", "CHANGES_REQUESTED"}:
78+
continue
79+
submitted_at = parse_github_timestamp(review.get("submitted_at"))
80+
if submitted_at is None or submitted_at < boundary:
81+
continue
82+
commit_id = review.get("commit_id")
83+
if not isinstance(commit_id, str) or not commit_id.strip():
84+
continue
85+
review_id = str(review.get("id", ""))
86+
review_key = (submitted_at, review_id)
87+
if review_key >= latest_key:
88+
latest_key = review_key
89+
latest_review = review
90+
return latest_review
91+
92+
93+
def build_reviewer_review_record_from_live_review(review: dict, *, actor: str | None = None) -> dict | None:
94+
if not isinstance(review, dict):
95+
return None
96+
review_id = review.get("id")
97+
submitted_at = review.get("submitted_at")
98+
commit_id = review.get("commit_id")
99+
author = actor if isinstance(actor, str) and actor.strip() else review.get("user", {}).get("login")
100+
if not isinstance(review_id, int) or not isinstance(submitted_at, str) or not isinstance(commit_id, str):
101+
return None
102+
if not isinstance(author, str) or not author.strip():
103+
return None
104+
return {
105+
"semantic_key": f"pull_request_review:{review_id}",
106+
"timestamp": submitted_at,
107+
"actor": author,
108+
"reviewed_head_sha": commit_id,
109+
"source_precedence": 1,
110+
"payload": {},
111+
}
112+
113+
114+
def accept_reviewer_review_from_live_review(review_data: dict, review: dict, *, actor: str | None = None) -> bool:
115+
record = build_reviewer_review_record_from_live_review(review, actor=actor)
116+
if record is None:
117+
return False
118+
return accept_channel_event(
119+
review_data,
120+
"reviewer_review",
121+
semantic_key=record["semantic_key"],
122+
timestamp=record["timestamp"],
123+
actor=record["actor"],
124+
reviewed_head_sha=record["reviewed_head_sha"],
125+
source_precedence=record["source_precedence"],
126+
payload=record["payload"],
127+
)
128+
129+
130+
def repair_missing_reviewer_review_state(bot, issue_number: int, review_data: dict, *, reviews: list[dict] | None = None) -> bool:
131+
reviewer_review = review_data.get("reviewer_review", {}).get("accepted")
132+
if isinstance(reviewer_review, dict):
133+
return False
134+
live_review = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data, reviews=reviews)
135+
if live_review is None:
136+
return False
137+
submitted_at = live_review.get("submitted_at")
138+
changed = accept_reviewer_review_from_live_review(review_data, live_review, actor=review_data.get("current_reviewer"))
139+
if isinstance(submitted_at, str):
140+
record_reviewer_activity(review_data, submitted_at)
141+
return changed
142+
143+
51144
def find_triage_approval_after(bot, reviews: list[dict], since: datetime | None) -> tuple[str, datetime] | None:
52145
permission_cache: dict[str, bool] = {}
53146
approvals: list[tuple[datetime, str, str]] = []
@@ -180,7 +273,10 @@ def clear_transition_timers(review_data: dict) -> None:
180273

181274

182275
def record_reviewer_activity(review_data: dict, timestamp: str) -> None:
183-
review_data["last_reviewer_activity"] = timestamp
276+
current = parse_github_timestamp(review_data.get("last_reviewer_activity"))
277+
candidate = parse_github_timestamp(timestamp)
278+
if current is None or candidate is None or candidate >= current:
279+
review_data["last_reviewer_activity"] = timestamp
184280
clear_transition_timers(review_data)
185281

186282

@@ -554,6 +650,11 @@ def project_status_labels_for_item(
554650
reviewer_review = review_data.get("reviewer_review", {}).get("accepted")
555651
contributor_comment = review_data.get("contributor_comment", {}).get("accepted")
556652

653+
if not reviewer_comment and not reviewer_review and is_pr:
654+
live_review = get_latest_valid_current_reviewer_review_for_cycle(bot, issue_number, review_data)
655+
if live_review is not None:
656+
reviewer_review = build_reviewer_review_record_from_live_review(live_review, actor=current_reviewer)
657+
557658
if not reviewer_comment and not reviewer_review:
558659
return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "no_reviewer_activity"})
559660

@@ -572,7 +673,10 @@ def project_status_labels_for_item(
572673
if not isinstance(latest_review_head, str) or latest_review_head != current_head:
573674
return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "review_head_stale"})
574675

575-
if _compare_cross_channel_conversation(contributor_comment, reviewer_comment) > 0:
676+
latest_reviewer_response = reviewer_comment
677+
if _compare_records(reviewer_review, latest_reviewer_response) > 0:
678+
latest_reviewer_response = reviewer_review
679+
if _compare_cross_channel_conversation(contributor_comment, latest_reviewer_response) > 0:
576680
return ({STATUS_AWAITING_REVIEWER_RESPONSE_LABEL}, {"state": "awaiting_reviewer_response", "reason": "contributor_comment_newer"})
577681

578682
if is_pr:

0 commit comments

Comments
 (0)