Skip to content

Commit 55f6e00

Browse files
committed
fix: harden reviewer-bot reconcile freshness
1 parent d39e835 commit 55f6e00

12 files changed

Lines changed: 191 additions & 92 deletions

File tree

.github/workflows/reviewer-bot-reconcile.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ env:
1717

1818
jobs:
1919
reconcile:
20-
if: ${{ github.event.workflow_run.conclusion == 'success' }}
2120
runs-on: ubuntu-latest
2221
permissions:
2322
# Temporary lock debt: contents:write is allowed only for the existing lock-ref API operations.

scripts/reviewer_bot_lib/app.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ def record_state_issue_write_receipt(
117117
return receipt
118118

119119

120+
def _store_state_issue_write_receipt(state: dict, receipt: StateIssueWriteReceipt) -> bool:
121+
receipts = state.setdefault("state_issue_write_receipts", {})
122+
if not isinstance(receipts, dict):
123+
receipts = {}
124+
state["state_issue_write_receipts"] = receipts
125+
key = f"{receipt.mutation_kind}:{receipt.issue_number or 'global'}:{receipt.recorded_at or 'pending'}"
126+
receipts[key] = receipt.to_output()
127+
return True
128+
129+
120130
def _stable_state_hash(state: dict | None) -> str | None:
121131
if not isinstance(state, dict):
122132
return None
@@ -485,6 +495,17 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
485495
workflow_run_result=workflow_run_result,
486496
)
487497
state_hash_after = _stable_state_hash(state)
498+
success_receipt = build_state_issue_write_receipt(
499+
issue_number=_primary_issue_number(context, touched_items),
500+
mutation_kind=f"{event_name}:{event_action or context.manual_action or 'none'}",
501+
issue_body_hash_before=loaded_state_hash,
502+
issue_body_hash_after=state_hash_after,
503+
external_side_effects_recorded=save_side_effects,
504+
state_save_attempted=True,
505+
state_save_succeeded=True,
506+
recorded_at=bot.datetime.now(bot.timezone.utc).isoformat(),
507+
)
508+
_store_state_issue_write_receipt(state, success_receipt)
488509
if not bot.state_store.save_state(state):
489510
record_state_issue_write_receipt(
490511
bot,
@@ -501,16 +522,8 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
501522
"State updates were computed but could not be persisted. "
502523
"Failing this run to avoid silent success."
503524
)
504-
record_state_issue_write_receipt(
505-
bot,
506-
issue_number=_primary_issue_number(context, touched_items),
507-
mutation_kind=f"{event_name}:{event_action or context.manual_action or 'none'}",
508-
issue_body_hash_before=loaded_state_hash,
509-
issue_body_hash_after=state_hash_after,
510-
external_side_effects_recorded=save_side_effects,
511-
state_save_attempted=True,
512-
state_save_succeeded=True,
513-
)
525+
if hasattr(bot, "logger"):
526+
bot.logger.event("info", "state issue write receipt", **success_receipt.to_output())
514527

515528
if touched_items:
516529
state = bot.state_store.load_state(fail_on_unavailable=True)

scripts/reviewer_bot_lib/overdue.py

Lines changed: 65 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6+
from datetime import datetime, timezone
67

78
from . import assignment_flow
89
from .config import TRANSITION_NOTICE_MARKER_PREFIX, TRANSITION_WARNING_MARKER_PREFIX
@@ -253,6 +254,18 @@ def build_reminder_delivery_persistence_result(
253254
)
254255

255256

257+
def _parse_reminder_timestamp(value: object) -> datetime | None:
258+
if isinstance(value, datetime):
259+
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
260+
if not isinstance(value, str) or not value.strip():
261+
return None
262+
try:
263+
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
264+
except ValueError:
265+
return None
266+
return timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=timezone.utc)
267+
268+
256269
def derive_reminder_cadence_decision(
257270
response,
258271
*,
@@ -262,23 +275,38 @@ def derive_reminder_cadence_decision(
262275
review_deadline_days: int,
263276
transition_period_days: int,
264277
) -> ReminderCadenceDecision:
265-
del now, review_deadline_days, transition_period_days
266278
issue_number = int(getattr(response, "scope", None).issue_number or 0) if getattr(response, "scope", None) else 0
267279
reviewer = getattr(getattr(response, "scope", None), "reviewer", None)
268280
effective_receipt = receipt if receipt is not None and receipt.receipt_kind != "none" else None
269281
legacy_duplicate_count = reminder_scan.baseline_count if reminder_scan is not None else 0
270282
exhausted = (effective_receipt is not None and effective_receipt.receipt_kind in {"transition", "legacy_transition"}) or legacy_duplicate_count >= 2
283+
now_dt = _parse_reminder_timestamp(now)
284+
anchor_dt = _parse_reminder_timestamp(getattr(response, "anchor_timestamp", None))
285+
receipt_dt = _parse_reminder_timestamp(effective_receipt.created_at if effective_receipt else None)
286+
cadence_state = "exhausted" if exhausted else "not_started"
287+
may_post_warning = False
288+
may_post_transition = False
289+
if not exhausted and getattr(response, "response_state", None) == "awaiting_reviewer_response":
290+
if effective_receipt is not None and effective_receipt.receipt_kind in {"warning", "legacy_warning_or_reminder"}:
291+
if now_dt is not None and receipt_dt is not None and (now_dt - receipt_dt).days >= transition_period_days:
292+
cadence_state = "transition_due"
293+
may_post_transition = True
294+
elif effective_receipt is None and now_dt is not None and anchor_dt is not None and (now_dt - anchor_dt).days >= review_deadline_days:
295+
cadence_state = "warning_due"
296+
may_post_warning = True
297+
elif now_dt is None or (effective_receipt is None and anchor_dt is None):
298+
cadence_state = "blocked"
271299
return ReminderCadenceDecision(
272300
issue_number=issue_number,
273301
reviewer=reviewer,
274302
scope=getattr(response, "scope", None),
275-
cadence_state="exhausted" if exhausted else "not_started",
303+
cadence_state=cadence_state,
276304
exhaustion_reason="legacy_duplicate_reminders_exhausted" if legacy_duplicate_count >= 2 else "transition_notice_sent" if exhausted else "not_exhausted",
277305
warning_receipt=effective_receipt if effective_receipt and effective_receipt.receipt_kind in {"warning", "legacy_warning_or_reminder"} else None,
278306
transition_receipt=effective_receipt if effective_receipt and effective_receipt.receipt_kind in {"transition", "legacy_transition"} else None,
279307
legacy_duplicate_count=legacy_duplicate_count,
280-
may_post_warning=not exhausted and effective_receipt is None,
281-
may_post_transition=not exhausted and effective_receipt is not None and effective_receipt.receipt_kind == "warning",
308+
may_post_warning=may_post_warning,
309+
may_post_transition=may_post_transition,
282310
must_project_reassignment_needed=exhausted,
283311
)
284312

@@ -295,10 +323,10 @@ def decide_overdue_reminder(
295323
if getattr(response, "response_state", None) != "awaiting_reviewer_response" or cadence.must_project_reassignment_needed:
296324
action = "none"
297325
reason = cadence.exhaustion_reason or "not_awaiting_reviewer_response"
298-
elif cadence.may_post_transition:
326+
elif cadence.cadence_state == "transition_due" and cadence.may_post_transition:
299327
action = "transition"
300328
reason = "transition_due"
301-
elif cadence.may_post_warning:
329+
elif cadence.cadence_state == "warning_due" and cadence.may_post_warning:
302330
action = "warning"
303331
reason = "warning_due"
304332
else:
@@ -469,16 +497,16 @@ def _effective_response_with_cadence(bot, issue_number: int, review_data: dict,
469497
response_payload.setdefault("issue_number", issue_number)
470498
response_payload.setdefault("current_reviewer", review_data.get("current_reviewer"))
471499
response = reviewer_response_policy.to_reviewer_response_decision(response_payload)
500+
reminder_scan = _scan_live_reminder_comments(bot, issue_number)
472501
receipt = derive_reminder_scope_receipt(
473502
issue_number=issue_number,
474503
reviewer=getattr(response.scope, "reviewer", None) if response.scope else review_data.get("current_reviewer"),
475504
head_sha=getattr(response.scope, "head_sha", None) if response.scope else None,
476505
cycle_key=getattr(response.scope, "cycle_key", None) if response.scope else None,
477506
scope_key=getattr(response.scope, "scope_key", None) if response.scope else None,
478507
persisted_state=review_data,
479-
scanned_comments=(),
508+
scanned_comments=reminder_scan.records if reminder_scan is not None else (),
480509
)
481-
reminder_scan = _scan_live_reminder_comments(bot, issue_number)
482510
cadence = derive_reminder_cadence_decision(
483511
response,
484512
receipt=receipt,
@@ -598,46 +626,15 @@ def evaluate_overdue_review_preview(bot, state: dict, issue_number: int) -> dict
598626
}
599627
if authority["authority_status"] != "tracked_reviewer_confirmed":
600628
return preview
601-
if cadence.must_project_reassignment_needed:
602-
return preview
603-
if response_name != "awaiting_reviewer_response":
604-
return preview
605-
current_reviewer = review_data.get("current_reviewer")
606-
anchor_timestamp = effective_response.anchor_timestamp
607-
if not isinstance(current_reviewer, str) or not current_reviewer.strip() or not isinstance(anchor_timestamp, str) or not anchor_timestamp:
608-
return preview
609-
try:
610-
now = bot.datetime.now(bot.timezone.utc)
611-
anchor_dt = bot.datetime.fromisoformat(anchor_timestamp.replace("Z", "+00:00"))
612-
except (ValueError, AttributeError):
613-
return preview
614-
transition_warning_sent = review_data.get("transition_warning_sent")
615-
if isinstance(transition_warning_sent, str) and transition_warning_sent:
616-
try:
617-
warning_dt = bot.datetime.fromisoformat(transition_warning_sent.replace("Z", "+00:00"))
618-
except (ValueError, AttributeError):
619-
return preview
620-
if (now - warning_dt).days < bot.TRANSITION_PERIOD_DAYS:
621-
return preview
622-
existing_notice = find_existing_transition_notice_result(
623-
bot,
624-
issue_number,
625-
transition_warning_sent,
626-
current_reviewer,
627-
)
628-
preview["would_post_transition"] = existing_notice.get("status") == "missing"
629-
return preview
630-
if (now - anchor_dt).days < bot.REVIEW_DEADLINE_DAYS:
631-
return preview
632-
existing_warning = _find_existing_warning_comment(
633-
bot,
634-
issue_number,
635-
current_reviewer,
636-
anchor_timestamp,
637-
current_scope_key=current_scope_key,
638-
current_scope_basis=current_scope_basis,
629+
reminder_decision = decide_overdue_reminder(
630+
effective_response,
631+
cadence=cadence,
632+
now=bot.datetime.now(bot.timezone.utc),
633+
review_deadline_days=bot.REVIEW_DEADLINE_DAYS,
634+
transition_period_days=bot.TRANSITION_PERIOD_DAYS,
639635
)
640-
preview["would_post_warning"] = existing_warning.get("status") == "missing"
636+
preview["would_post_warning"] = reminder_decision.action == "warning"
637+
preview["would_post_transition"] = reminder_decision.action == "transition"
641638
return preview
642639

643640

@@ -661,9 +658,6 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]:
661658
if review_data.get("review_completed_at"):
662659
continue
663660

664-
if review_data.get("transition_notice_sent_at"):
665-
continue
666-
667661
current_reviewer = review_data.get("current_reviewer")
668662
if not current_reviewer:
669663
continue
@@ -756,30 +750,27 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]:
756750
if days_since_activity < bot.REVIEW_DEADLINE_DAYS:
757751
continue
758752

759-
transition_warning_sent = review_data.get("transition_warning_sent")
760-
if transition_warning_sent:
761-
try:
762-
warning_dt = bot.datetime.fromisoformat(transition_warning_sent.replace("Z", "+00:00"))
763-
days_since_warning = (now - warning_dt).days
764-
765-
if days_since_warning >= bot.TRANSITION_PERIOD_DAYS:
766-
overdue.append(
767-
{
768-
"issue_number": issue_number,
769-
"reviewer": current_reviewer,
770-
"days_overdue": days_since_activity,
771-
"days_since_warning": days_since_warning,
772-
"needs_warning": False,
773-
"needs_transition": True,
774-
"anchor_reason": anchor_reason,
775-
"anchor_timestamp": last_activity,
776-
"current_scope_key": current_scope_key,
777-
"current_scope_basis": current_scope_basis,
778-
}
779-
)
780-
except (ValueError, AttributeError):
781-
pass
782-
else:
753+
if reminder_decision.action == "transition":
754+
warning_dt = _parse_reminder_timestamp(
755+
reminder_decision.receipt.created_at if reminder_decision.receipt is not None else None
756+
)
757+
if warning_dt is None:
758+
continue
759+
overdue.append(
760+
{
761+
"issue_number": issue_number,
762+
"reviewer": current_reviewer,
763+
"days_overdue": days_since_activity,
764+
"days_since_warning": (now - warning_dt).days,
765+
"needs_warning": False,
766+
"needs_transition": True,
767+
"anchor_reason": anchor_reason,
768+
"anchor_timestamp": last_activity,
769+
"current_scope_key": current_scope_key,
770+
"current_scope_basis": current_scope_basis,
771+
}
772+
)
773+
elif reminder_decision.action == "warning":
783774
overdue.append(
784775
{
785776
"issue_number": issue_number,

scripts/reviewer_bot_lib/reconcile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ def record_artifact_invalid(problem: InvalidEventInput) -> bool:
790790
if (
791791
decision.mark_reconciled
792792
and admission.mark_reconciled_allowed
793-
and command_receipt.result in {"pass_replayed_and_persisted", "pass_diagnostic_only"}
793+
and command_receipt.result == "pass_replayed_and_persisted"
794794
):
795795
reconciled_changed = gap_bookkeeping.mark_reconciled_source_event(
796796
review_data,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
pytestmark = pytest.mark.contract
6+
7+
8+
def _shipped_behavior_files() -> list[Path]:
9+
roots = [Path("scripts/reviewer_bot_core"), Path("scripts/reviewer_bot_lib"), Path(".github/workflows")]
10+
files: list[Path] = []
11+
for root in roots:
12+
files.extend(path for path in root.rglob("*") if path.is_file() and path.suffix in {".py", ".yml", ".yaml"})
13+
files.extend(path for path in Path("docs").glob("reviewer-bot-*.md") if path.is_file())
14+
return sorted(files)
15+
16+
17+
def test_shipped_reviewer_bot_behavior_does_not_depend_on_opencode_tooling():
18+
forbidden = ("OPENCODE_", "OPENCODE_CONFIG_DIR", "opencode-project-agents")
19+
offenders: list[str] = []
20+
for path in _shipped_behavior_files():
21+
text = path.read_text(encoding="utf-8")
22+
if any(token in text for token in forbidden):
23+
offenders.append(str(path))
24+
25+
assert offenders == []

tests/contract/reviewer_bot/test_workflow_files.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ def test_reconcile_workflow_permissions_cover_live_replay_reads():
108108
assert permissions["pull-requests"] in {"read", "write"}
109109

110110

111+
def test_reconcile_workflow_does_not_gate_completed_observers_by_success():
112+
workflow_text = Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8")
113+
workflow = yaml.safe_load(workflow_text)
114+
reconcile_job = workflow["jobs"]["reconcile"]
115+
116+
assert "if" not in reconcile_job
117+
assert "github.event.workflow_run.conclusion == 'success'" not in workflow_text
118+
assert "WORKFLOW_RUN_TRIGGERING_CONCLUSION: ${{ github.event.workflow_run.conclusion }}" in workflow_text
119+
120+
111121
@pytest.mark.parametrize(
112122
"workflow_path",
113123
[
@@ -402,6 +412,17 @@ def test_workflow_policy_split_and_lock_only_boundaries():
402412
else:
403413
assert "@" in value and len(value.split("@", 1)[1]) == 40
404414

415+
416+
def test_reviewer_bot_workflows_use_shared_source_action_without_raw_extraction():
417+
workflows_dir = Path(".github/workflows")
418+
for path in sorted(workflows_dir.glob("reviewer-bot-*.yml")):
419+
text = path.read_text(encoding="utf-8")
420+
assert "archive.extractall(" not in text
421+
assert "tar.extractall(" not in text
422+
if 'uv run --project "$BOT_SRC_ROOT"' in text or 'python "$BOT_SRC_ROOT/scripts/reviewer_bot.py"' in text:
423+
assert "uses: ./.github/actions/reviewer-bot-source" in text
424+
assert "BOT_SRC_ROOT: ${{ steps.bot-source.outputs.bot-src-root }}" in text
425+
405426
def test_workflow_summaries_and_runbook_references_exist():
406427
runbook = Path("docs/reviewer-bot-review-freshness-operator-runbook.md")
407428
assert runbook.exists()

tests/integration/reviewer_bot/test_app_execution.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,9 @@ def test_execute_run_opened_issue_adopts_existing_single_live_assignee(monkeypat
343343
assert result.exit_code == 0
344344
assert result.state_changed is True
345345
assert saved[-1]["active_reviews"]["42"]["current_reviewer"] == "alice"
346+
receipt = next(iter(saved[-1]["state_issue_write_receipts"].values()))
347+
assert receipt["write_status"] == "persisted"
348+
assert receipt["state_save_succeeded"] is True
346349

347350
def test_execute_run_returns_failure_when_save_state_fails(monkeypatch):
348351
harness = AppHarness(monkeypatch)
@@ -358,6 +361,9 @@ def test_execute_run_returns_failure_when_save_state_fails(monkeypatch):
358361

359362
assert result.exit_code == 1
360363
assert result.state_changed is True
364+
receipt_logs = [record for record in harness.runtime.logger.records if record["message"] == "state issue write receipt"]
365+
assert receipt_logs[-1]["fields"]["write_status"] == "failed_after_external_side_effect"
366+
assert receipt_logs[-1]["fields"]["next_recovery_action"] == "recover_from_live_github_receipt"
361367

362368

363369
def test_execute_run_releases_lock_after_save_failure(monkeypatch):

tests/integration/reviewer_bot/test_pr264_incident_replay_card.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
pytestmark = pytest.mark.integration
1515

1616

17-
def test_pr264_canonical_replay_card_suppresses_same_scope_reviewer_activity_after_reconcile(monkeypatch):
17+
def test_pr264_canonical_replay_card_keeps_plain_lgtm_diagnostic_only(monkeypatch):
1818
state = make_state()
1919
review = make_tracked_review_state(
2020
state,
@@ -47,9 +47,10 @@ def test_pr264_canonical_replay_card_suppresses_same_scope_reviewer_activity_aft
4747
author_association="CONTRIBUTOR",
4848
)
4949

50-
assert harness.run(state) is True
50+
assert harness.run(state) is False
5151
assert review["reviewer_comment"].get("accepted") is None
52-
assert review["sidecars"]["reconciled_source_events"]["issue_comment:210"]["source_event_key"] == "issue_comment:210"
52+
assert "issue_comment:210" not in review["sidecars"]["reconciled_source_events"]
53+
assert "issue_comment:210" not in review["sidecars"]["deferred_gaps"]
5354

5455
routes = RouteGitHubApi().add_request(
5556
"GET",

0 commit comments

Comments
 (0)