fix(pr-review): allow re-review when previous review failed (#1268)

* fix(pr-review): allow re-review when previous review failed

Previously, when a PR review failed (e.g., SDK validation error), the
bot detector would mark the commit as 'already reviewed' and refuse to
retry. This caused instant failures on subsequent review attempts.

Now, the orchestrator checks if the existing review was successful before
returning it. Failed reviews are no longer treated as blocking - instead,
the system allows a fresh review attempt.

Fixes: PR reviews failing instantly with 'Review failed: None'

* fix(pr-review): address PR review feedback

- Rename test_failed_review_allows_re_review to test_failed_review_model_persistence
  with updated docstring to accurately reflect what it tests (model persistence,
  not orchestrator re-review behavior)
- Extract duplicate skip result creation into _create_skip_result helper method
  to reduce code duplication in orchestrator.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-17 23:17:19 +01:00
committed by GitHub
parent d7ed770ed8
commit 4cc8f4dbfd
2 changed files with 97 additions and 19 deletions
+42 -13
View File
@@ -279,6 +279,33 @@ class GitHubOrchestrator:
flush=True, flush=True,
) )
# =========================================================================
# Helper Methods
# =========================================================================
async def _create_skip_result(
self, pr_number: int, skip_reason: str
) -> PRReviewResult:
"""Create and save a skip result for a PR that should not be reviewed.
Args:
pr_number: The PR number
skip_reason: Reason why the review was skipped
Returns:
PRReviewResult with success=True and skip reason in summary
"""
result = PRReviewResult(
pr_number=pr_number,
repo=self.config.repo,
success=True,
findings=[],
summary=f"Skipped review: {skip_reason}",
overall_status="comment",
)
await result.save(self.github_dir)
return result
# ========================================================================= # =========================================================================
# PR REVIEW WORKFLOW # PR REVIEW WORKFLOW
# ========================================================================= # =========================================================================
@@ -349,7 +376,9 @@ class GitHubOrchestrator:
# instead of creating a new empty "skipped" result # instead of creating a new empty "skipped" result
if "Already reviewed" in skip_reason: if "Already reviewed" in skip_reason:
existing_review = PRReviewResult.load(self.github_dir, pr_number) existing_review = PRReviewResult.load(self.github_dir, pr_number)
if existing_review: # Only return existing review if it was successful
# A failed review should not block re-review attempts
if existing_review and existing_review.success:
safe_print( safe_print(
"[BOT DETECTION] Returning existing review (no new commits)", "[BOT DETECTION] Returning existing review (no new commits)",
flush=True, flush=True,
@@ -357,18 +386,18 @@ class GitHubOrchestrator:
# Don't overwrite - return the existing review as-is # Don't overwrite - return the existing review as-is
# The frontend will see "no new commits" via the newCommitsCheck # The frontend will see "no new commits" via the newCommitsCheck
return existing_review return existing_review
elif existing_review and not existing_review.success:
# For other skip reasons (bot-authored, cooling off), create a skip result safe_print(
result = PRReviewResult( "[BOT DETECTION] Previous review failed, allowing re-review",
pr_number=pr_number, flush=True,
repo=self.config.repo, )
success=True, # Fall through to perform a new review (don't return here)
findings=[], else:
summary=f"Skipped review: {skip_reason}", # No existing review found, create skip result
overall_status="comment", return await self._create_skip_result(pr_number, skip_reason)
) else:
await result.save(self.github_dir) # For other skip reasons (bot-authored, cooling off), create a skip result
return result return await self._create_skip_result(pr_number, skip_reason)
self._report_progress( self._report_progress(
"analyzing", 30, "Running multi-pass review...", pr_number=pr_number "analyzing", 30, "Running multi-pass review...", pr_number=pr_number
+55 -6
View File
@@ -37,6 +37,7 @@ from bot_detection import BotDetector, BotDetectionState
# Fixtures # Fixtures
# ============================================================================ # ============================================================================
@pytest.fixture @pytest.fixture
def temp_github_dir(tmp_path): def temp_github_dir(tmp_path):
"""Create temporary GitHub directory structure.""" """Create temporary GitHub directory structure."""
@@ -98,6 +99,7 @@ def mock_bot_detector(tmp_path):
# PRReviewResult Tests # PRReviewResult Tests
# ============================================================================ # ============================================================================
class TestPRReviewResult: class TestPRReviewResult:
"""Test PRReviewResult model.""" """Test PRReviewResult model."""
@@ -108,7 +110,9 @@ class TestPRReviewResult:
await sample_review_result.save(temp_github_dir) await sample_review_result.save(temp_github_dir)
# Verify file exists # Verify file exists
review_file = temp_github_dir / "pr" / f"review_{sample_review_result.pr_number}.json" review_file = (
temp_github_dir / "pr" / f"review_{sample_review_result.pr_number}.json"
)
assert review_file.exists() assert review_file.exists()
# Load # Load
@@ -192,6 +196,7 @@ class TestPRReviewFinding:
# Follow-up Review Context Tests # Follow-up Review Context Tests
# ============================================================================ # ============================================================================
class TestFollowupReviewContext: class TestFollowupReviewContext:
"""Test FollowupReviewContext model.""" """Test FollowupReviewContext model."""
@@ -225,7 +230,9 @@ class TestFollowupReviewContext:
assert context.error is not None assert context.error is not None
assert "Failed to compare commits" in context.error assert "Failed to compare commits" in context.error
def test_context_rebase_detected_files_changed_no_commits(self, sample_review_result): def test_context_rebase_detected_files_changed_no_commits(
self, sample_review_result
):
"""Test follow-up context when PR was rebased (files changed but no trackable commits). """Test follow-up context when PR was rebased (files changed but no trackable commits).
After a rebase/force-push, commit SHAs are rewritten so we can't identify "new" commits. After a rebase/force-push, commit SHAs are rewritten so we can't identify "new" commits.
@@ -238,7 +245,10 @@ class TestFollowupReviewContext:
previous_commit_sha="abc123", # This SHA no longer exists in PR after rebase previous_commit_sha="abc123", # This SHA no longer exists in PR after rebase
current_commit_sha="xyz789", current_commit_sha="xyz789",
commits_since_review=[], # Empty after rebase - can't determine "new" commits commits_since_review=[], # Empty after rebase - can't determine "new" commits
files_changed_since_review=["src/db.py", "src/api.py"], # But blob comparison found changes files_changed_since_review=[
"src/db.py",
"src/api.py",
], # But blob comparison found changes
diff_since_review="--- a/src/db.py\n+++ b/src/db.py\n@@ -1,3 +1,3 @@\n-old\n+new", diff_since_review="--- a/src/db.py\n+++ b/src/db.py\n@@ -1,3 +1,3 @@\n-old\n+new",
) )
@@ -253,7 +263,9 @@ class TestFollowupReviewContext:
has_changes = bool(context.commits_since_review) or bool( has_changes = bool(context.commits_since_review) or bool(
context.files_changed_since_review context.files_changed_since_review
) )
assert has_changes is True, "Rebase with file changes should be treated as having changes" assert has_changes is True, (
"Rebase with file changes should be treated as having changes"
)
def test_context_truly_no_changes(self, sample_review_result): def test_context_truly_no_changes(self, sample_review_result):
"""Test follow-up context when there are truly no changes (same SHA, no files).""" """Test follow-up context when there are truly no changes (same SHA, no files)."""
@@ -278,6 +290,7 @@ class TestFollowupReviewContext:
# Bot Detection Integration Tests # Bot Detection Integration Tests
# ============================================================================ # ============================================================================
class TestBotDetectionIntegration: class TestBotDetectionIntegration:
"""Test bot detection integration with review flow.""" """Test bot detection integration with review flow."""
@@ -332,11 +345,14 @@ class TestBotDetectionIntegration:
# Orchestrator Skip Logic Tests # Orchestrator Skip Logic Tests
# ============================================================================ # ============================================================================
class TestOrchestratorSkipLogic: class TestOrchestratorSkipLogic:
"""Test orchestrator behavior when bot detection skips.""" """Test orchestrator behavior when bot detection skips."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skip_returns_existing_review(self, temp_github_dir, sample_review_result): async def test_skip_returns_existing_review(
self, temp_github_dir, sample_review_result
):
"""Test that skipping 'Already reviewed' returns existing review.""" """Test that skipping 'Already reviewed' returns existing review."""
# Save existing review # Save existing review
await sample_review_result.save(temp_github_dir) await sample_review_result.save(temp_github_dir)
@@ -371,11 +387,39 @@ class TestOrchestratorSkipLogic:
assert len(result.findings) == 0 assert len(result.findings) == 0
assert "bot user" in result.summary assert "bot user" in result.summary
@pytest.mark.asyncio
async def test_failed_review_model_persistence(self, temp_github_dir):
"""Test that a failed PRReviewResult can be saved and loaded with success=False.
This verifies that the model correctly persists failure state, which is
a prerequisite for the orchestrator's re-review logic (tested separately
in TestOrchestratorReReviewLogic).
"""
failed_review = PRReviewResult(
pr_number=789,
repo="test/repo",
success=False,
findings=[],
summary="Review failed: SDK validation error",
overall_status="comment",
error="SDK stream processing failed",
reviewed_commit_sha="abc123def456",
)
await failed_review.save(temp_github_dir)
# Verify the failed review can be loaded and maintains its failure state
loaded_review = PRReviewResult.load(temp_github_dir, 789)
assert loaded_review is not None
assert loaded_review.success is False
assert loaded_review.error == "SDK stream processing failed"
assert loaded_review.reviewed_commit_sha == "abc123def456"
# ============================================================================ # ============================================================================
# Follow-up Review Logic Tests # Follow-up Review Logic Tests
# ============================================================================ # ============================================================================
class TestFollowupReviewLogic: class TestFollowupReviewLogic:
"""Test follow-up review resolution logic.""" """Test follow-up review resolution logic."""
@@ -445,6 +489,7 @@ class TestFollowupReviewLogic:
# Posted Findings Tracking Tests # Posted Findings Tracking Tests
# ============================================================================ # ============================================================================
class TestPostedFindingsTracking: class TestPostedFindingsTracking:
"""Test posted findings tracking for follow-up eligibility.""" """Test posted findings tracking for follow-up eligibility."""
@@ -462,7 +507,9 @@ class TestPostedFindingsTracking:
assert len(sample_review_result.posted_finding_ids) == 1 assert len(sample_review_result.posted_finding_ids) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_posted_findings_serialization(self, temp_github_dir, sample_review_result): async def test_posted_findings_serialization(
self, temp_github_dir, sample_review_result
):
"""Test that posted findings are serialized correctly.""" """Test that posted findings are serialized correctly."""
# Set posted findings # Set posted findings
sample_review_result.has_posted_findings = True sample_review_result.has_posted_findings = True
@@ -484,6 +531,7 @@ class TestPostedFindingsTracking:
# Error Handling Tests # Error Handling Tests
# ============================================================================ # ============================================================================
class TestErrorHandling: class TestErrorHandling:
"""Test error handling in review flow.""" """Test error handling in review flow."""
@@ -539,6 +587,7 @@ class TestErrorHandling:
# Blocker Generation Tests # Blocker Generation Tests
# ============================================================================ # ============================================================================
class TestBlockerGeneration: class TestBlockerGeneration:
"""Test blocker generation from findings.""" """Test blocker generation from findings."""