fix(ui): add Post Clean Review button for clean PR reviews (ACS-201) (#894)

* fix(ui): add Post Clean Review button for clean PR reviews (ACS-201)

When a PR review completes with no findings or only LOW severity findings,
users can now post a clean review comment to GitHub. This posts a COMMENT
(not APPROVE/REQUEST_CHANGES) to document the review without changing the
PR's review status.

Changes:
- Add "Post Clean Review" button when review is clean and no findings selected
- Add translation keys for clean review button (en/fr)
- Add 29 unit tests for clean review functionality
- Reset clean review posted state when PR changes

Affected files:
- src/renderer/components/github-prs/components/PRDetail.tsx
- src/shared/i18n/locales/en/common.json
- src/shared/i18n/locales/fr/common.json
- src/renderer/components/github-prs/components/__tests__/PRDetail.cleanReview.test.ts

* test: improve clean review tests with integration tests and error handling

- Add error handling (try/catch) to handlePostCleanReview function
- Add PRDetail.integration.test.tsx with React Testing Library integration tests
- Update PRDetail.cleanReview.test.ts with improved state reset tests
- Tests verify cleanReviewPosted state resets when pr.number changes
- Tests verify button visibility based on review cleanliness

* test: fix TypeScript errors in integration tests

- Add required prNumber and repo to PRReviewResult mocks
- Add required title and fixable to PRReviewFinding mocks

* fix: add error handling and improve integration tests

PRDetail.tsx:
- Add cleanReviewError state for tracking posting errors
- Update handlePostCleanReview with proper try/catch/finally
- Set error message on failure, clear on success
- Display error in UI (red text with XCircle icon)
- Reset error state when PR changes

PRDetail.integration.test.tsx:
- Add renderPRDetail helper function to reduce code duplication
- Update first test to actually click button and verify success message
- Add error handling test for failed clean review posts
- Use fireEvent instead of userEvent (not installed)
- Tests verify full flow: click → wait for success → verify state reset

* fix: resolve TypeScript errors in integration test

- Import PRReviewResult from correct path (useGitHubPRs)
- Fix mock type to avoid explicit any warning

* fix(tests): resolve TypeScript errors in PRDetail integration test

Fixed Mock type assignment errors by:
- Defining PostCommentFn type alias matching (body: string) => void | Promise<void>
- Using vi.fn<PostCommentFn>() for properly typed mock
- Updating overrides.onPostComment type in renderPRDetail helper

Resolves CI TypeScript errors on lines 108, 171, 283.

* i18n: replace hardcoded clean review message with translation keys

Replace the inline cleanReviewMessage construction in handlePostCleanReview
with i18n translation keys for better localization support.

Changes:
- Added cleanReviewMessageTitle, cleanReviewMessageStatus, and
  cleanReviewMessageFooter keys to en/common.json
- Added corresponding French translations to fr/common.json
- Updated handlePostCleanReview to compose message from translation keys
- Removed comment about English being lingua franca (now translatable)

Error handling and behavior remain unchanged.

* fix: improve clean review error handling and prevent state leaks

This commit addresses several issues with the clean review functionality:

Error Display (line 832-860):
- Replace raw error display with normalized/friendly message
- Add "View details"/"Hide details" toggle for full error text
- Use translation key 'failedPostCleanReview' for user-facing message
- Log full error to console before rendering for debugging

Race Condition Prevention (line 737, 760):
- Post Clean Review button now checks (isPostingCleanReview || isPosting)
- Approve button now checks (isPosting || isPostingCleanReview)
- Both buttons disable during either posting operation

State Leak Prevention (line 542-645):
- Capture current pr.number at start of all posting handlers
- Guard all setState calls with pr.number === currentPr check
- Reset isPostingCleanReview in PR change effect (line 266)
- Use Promise.resolve() for onPostComment to handle non-Promise implementations

Locale Updates:
- Keep GitHub PR comments in English-only (lingua franca policy)
- French locale now uses same English text for comment messages
- Add French translations for UI error messages

Test Updates:
- Update integration test to check for normalized error message
- Add assertion for "View details" button presence

All posting handlers now consistently:
- Capture PR number before async operations
- Guard state updates against PR changes
- Clear loading state only if PR hasn't changed

* test: fix test issues and add accessibility attributes

Fixes for code review findings:

Test Fixes:
- Fix test 'should show clean review success message after posting clean review'
  to actually click the button and verify the success message appears
- Add documentation to unit tests clarifying they test algorithm, not React behavior
- Add JSDoc comment explaining algorithm tests vs integration tests

Accessibility:
- Add useId import and generate stable ID for error details
- Add aria-expanded and aria-controls attributes to error toggle button
- Add corresponding id to error details div for proper accessibility

Documentation:
- Add comment explaining inline error pattern vs Card-based pattern
- Document why action bar errors use inline layout for consistency

All 35 tests pass.

* feat: add startedAt prop to match upstream develop branch

Add startedAt: string | null property throughout the PR review state chain:
- PRDetailProps interface
- PRReviewState interface in pr-review-store.ts
- All store actions (startPRReview, startFollowupReview, setPRReviewProgress, setPRReviewResult, setPRReviewError, setNewCommitsCheck)
- UseGitHubPRsResult interface and hook extraction/return
- GitHubPRs.tsx destructuring and JSX props
- All PRDetail renders in integration tests

This aligns with upstream develop branch changes.

* fix: remove duplicate startedAt declarations

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-11 08:27:04 +02:00
committed by GitHub
parent 96fc61298d
commit f43c7c51dc
8 changed files with 1191 additions and 17 deletions
@@ -64,9 +64,9 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
selectedPRNumber,
reviewResult,
reviewProgress,
startedAt,
isReviewing,
previousReviewResult,
startedAt,
hasMore,
selectPR,
runReview,
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
Bot,
@@ -16,6 +16,8 @@ import {
ExternalLink,
Play,
Clock,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Badge } from '../../ui/badge';
import { Button } from '../../ui/button';
@@ -94,6 +96,10 @@ export function PRDetail({
const [isPostingFindings, setIsPostingFindings] = useState(false);
const [postSuccess, setPostSuccess] = useState<{ count: number; timestamp: number } | null>(null);
const [isPosting, setIsPosting] = useState(false);
const [isPostingCleanReview, setIsPostingCleanReview] = useState(false);
const [cleanReviewPosted, setCleanReviewPosted] = useState(false);
const [cleanReviewError, setCleanReviewError] = useState<string | null>(null);
const [showCleanReviewErrorDetails, setShowCleanReviewErrorDetails] = useState(false);
const [isMerging, setIsMerging] = useState(false);
// Initialize with store value, then sync and update via local checks
const [newCommitsCheck, setNewCommitsCheck] = useState<NewCommitsCheck | null>(initialNewCommitsCheck ?? null);
@@ -116,6 +122,9 @@ export function PRDetail({
const [isApprovingWorkflow, setIsApprovingWorkflow] = useState<number | null>(null);
const [workflowsExpanded, setWorkflowsExpanded] = useState(true);
// Generate stable IDs for accessibility
const cleanReviewErrorDetailsId = useId();
// Sync with store's newCommitsCheck when it changes (e.g., when switching PRs or after refresh)
// Always sync to keep local state in sync with store, including null values
useEffect(() => {
@@ -257,6 +266,10 @@ export function PRDetail({
logsLoadedRef.current = false;
setPrLogs(null);
setLogsExpanded(false);
setCleanReviewPosted(false);
setCleanReviewError(null);
setIsPostingCleanReview(false);
setShowCleanReviewErrorDetails(false);
}, [pr.number]);
// Check for workflows awaiting approval (fork PRs) when PR changes or review completes
@@ -543,11 +556,14 @@ export function PRDetail({
const idsToPost = Array.from(selectedFindingIds);
if (idsToPost.length === 0) return;
// Capture current PR number to prevent state leaks across PR switches
const currentPr = pr.number;
setIsPostingFindings(true);
try {
const success = await onPostReview(idsToPost);
if (success) {
// Mark these findings as posted
if (success && pr.number === currentPr) {
// Mark these findings as posted only if PR hasn't changed
setPostedFindingIds(prev => new Set([...prev, ...idsToPost]));
// Clear selection
setSelectedFindingIds(new Set());
@@ -558,21 +574,30 @@ export function PRDetail({
setTimeout(() => checkForNewCommits(), 500);
}
} finally {
setIsPostingFindings(false);
// Clear loading state if PR hasn't changed
if (pr.number === currentPr) {
setIsPostingFindings(false);
}
}
};
const handleApprove = async () => {
if (!reviewResult) return;
// Capture current PR number to prevent state leaks across PR switches
const currentPr = pr.number;
setIsPosting(true);
try {
// Auto-assign current user (you can get from GitHub config)
// For now, we'll just post the comment
const approvalMessage = `## ✅ Auto Claude PR Review - APPROVED\n\n${reviewResult.summary}\n\n---\n*This approval was generated by Auto Claude.*`;
await onPostComment(approvalMessage);
await Promise.resolve(onPostComment(approvalMessage));
} finally {
setIsPosting(false);
// Clear loading state if PR hasn't changed
if (pr.number === currentPr) {
setIsPosting(false);
}
}
};
@@ -582,6 +607,10 @@ export function PRDetail({
// content is meant to be read by contributors who may have different locales.
const handleAutoApprove = async () => {
if (!reviewResult) return;
// Capture current PR number to prevent state leaks across PR switches
const currentPr = pr.number;
setIsPosting(true);
try {
// Post approval with suggestions in a single review comment
@@ -589,12 +618,66 @@ export function PRDetail({
const lowFindingIds = lowSeverityFindings.map(f => f.id);
const success = await onPostReview(lowFindingIds, { forceApprove: true });
if (success && lowFindingIds.length > 0) {
// Mark findings as posted locally
if (success && lowFindingIds.length > 0 && pr.number === currentPr) {
// Mark findings as posted locally only if PR hasn't changed
setPostedFindingIds(prev => new Set([...prev, ...lowFindingIds]));
}
} finally {
setIsPosting(false);
// Clear loading state if PR hasn't changed
if (pr.number === currentPr) {
setIsPosting(false);
}
}
};
// Post clean review as a comment (does not change PR review status)
// This is for when a review has no findings or only LOW severity findings
// NOTE: GitHub PR comments are intentionally in English as it's the lingua franca
// for code reviews and GitHub's international developer community.
const handlePostCleanReview = async () => {
if (!reviewResult) return;
// Capture current PR number to prevent state leaks across PR switches
const currentPr = pr.number;
setIsPostingCleanReview(true);
setCleanReviewError(null); // Clear previous error
setShowCleanReviewErrorDetails(false); // Reset error details visibility
try {
// Format the clean review comment using i18n translations
const cleanReviewMessage = `${t('prReview.cleanReviewMessageTitle')}
${t('prReview.cleanReviewMessageStatus')}
${reviewResult.summary}
---
${t('prReview.cleanReviewMessageFooter')}`;
// Use Promise.resolve to handle both Promise and non-Promise implementations
await Promise.resolve(onPostComment(cleanReviewMessage));
// Only mark as posted on success if PR hasn't changed
if (pr.number === currentPr) {
setCleanReviewPosted(true);
setCleanReviewError(null);
}
} catch (err) {
// Log full error to console for debugging before rendering
console.error('Failed to post clean review comment:', err);
// Set user-friendly error message using translation key
const fullError = err instanceof Error ? err.message : String(err);
if (pr.number === currentPr) {
setCleanReviewError(fullError);
}
// Do NOT set cleanReviewPosted on failure
} finally {
// Clear loading state if PR hasn't changed
if (pr.number === currentPr) {
setIsPostingCleanReview(false);
}
}
};
@@ -677,12 +760,34 @@ export function PRDetail({
</Button>
)}
{/* Post Clean Review button - shows when review is clean and no findings are selected */}
{selectedCount === 0 && isCleanReview && !hasPostedFindings && !cleanReviewPosted && reviewResult?.overallStatus !== 'request_changes' && (
<Button
onClick={handlePostCleanReview}
disabled={isPostingCleanReview || isPosting}
variant="secondary"
className="flex-1 sm:flex-none"
>
{isPostingCleanReview ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('prReview.postingCleanReview')}
</>
) : (
<>
<MessageSquare className="h-4 w-4 mr-2" />
{t('prReview.postCleanReview')}
</>
)}
</Button>
)}
{/* Approve button - consolidated logic to avoid duplicate buttons */}
{/* Don't show when overallStatus is 'request_changes' (e.g., workflows blocked, or other issues) */}
{isCleanReview && !hasPostedFindings && reviewResult?.overallStatus !== 'request_changes' && (
<Button
onClick={handleAutoApprove}
disabled={isPosting}
disabled={isPosting || isPostingCleanReview}
variant="default"
className="flex-1 sm:flex-none bg-emerald-600 hover:bg-emerald-700 text-white"
>
@@ -746,6 +851,52 @@ export function PRDetail({
{t('prReview.postedFindings', { count: postSuccess.count })}
</div>
)}
{cleanReviewPosted && !postSuccess && (
<div className="ml-auto flex items-center gap-2 text-emerald-600 text-sm font-medium animate-pulse">
<CheckCircle className="h-4 w-4" />
{t('prReview.cleanReviewPosted')}
</div>
)}
{/* Clean review error display - inline pattern for action bar context */}
{/* Note: Uses inline layout (not Card) to match other action bar status messages.
Separate Card-based error at line 972 handles review result errors. */}
{cleanReviewError && (
<div className="ml-auto flex items-center gap-2">
<div className="flex items-center gap-2 text-destructive text-sm font-medium">
<XCircle className="h-4 w-4" />
{t('prReview.failedPostCleanReview')}
</div>
<button
onClick={() => setShowCleanReviewErrorDetails(!showCleanReviewErrorDetails)}
aria-expanded={showCleanReviewErrorDetails}
aria-controls={cleanReviewErrorDetailsId}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showCleanReviewErrorDetails ? (
<>
{t('prReview.hideErrorDetails')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
{t('prReview.viewErrorDetails')}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
)}
{cleanReviewError && showCleanReviewErrorDetails && (
<div
id={cleanReviewErrorDetailsId}
className="ml-auto text-xs text-muted-foreground max-w-md truncate"
title={cleanReviewError}
>
{cleanReviewError}
</div>
)}
</div>
)}
@@ -0,0 +1,658 @@
/**
* Unit tests for PRDetail clean review functionality
* Tests the "Post Clean Review" button visibility and behavior
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Types for PR review data
interface PRReviewFinding {
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
category: string;
file: string;
line: number;
description: string;
suggestedFix?: string;
}
interface PRReviewResult {
success: boolean;
overallStatus: 'approve' | 'request_changes' | 'comment';
summary: string;
findings: PRReviewFinding[];
reviewedCommitSha: string | null;
postedAt?: string;
postedFindingIds?: string[];
hasPostedFindings?: boolean;
isFollowupReview?: boolean;
resolvedFindings?: PRReviewFinding[];
unresolvedFindings?: PRReviewFinding[];
newFindingsSinceLastReview?: PRReviewFinding[];
}
// Helper to create test review results
function createReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
return {
success: true,
overallStatus: 'approve',
summary: 'Code review completed successfully. No issues found.',
findings: [],
reviewedCommitSha: 'abc123',
...overrides
};
}
function createTestFinding(severity: PRReviewFinding['severity'], id: string = 'finding-1'): PRReviewFinding {
return {
id,
severity,
category: 'quality',
file: 'src/test.ts',
line: 10,
description: `Test ${severity} severity issue`
};
}
describe('PRDetail Clean Review Functionality', () => {
beforeEach(() => {
vi.clearAllMocks();
});
/**
* NOTE: These tests verify the core ALGORITHM (isCleanReview logic, button visibility conditions).
*
* The actual React component behavior (rendering, user interactions, useEffect hooks) is tested
* in PRDetail.integration.test.tsx. These algorithm tests provide rapid feedback on logic
* changes but duplicate the implementation expressions. If the component logic changes,
* these tests must be updated to match.
*
* Alternative: Export isCleanReview as a pure function for direct testing.
*/
describe('isCleanReview Logic', () => {
it('should return true for review with no findings', () => {
const reviewResult = createReviewResult({
findings: []
});
// isCleanReview logic: success && no critical/high/medium findings
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(true);
expect(reviewResult.findings).toHaveLength(0);
});
it('should return true for review with only LOW severity findings', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('low', 'low-1'),
createTestFinding('low', 'low-2'),
createTestFinding('low', 'low-3')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(true);
expect(reviewResult.findings).toHaveLength(3);
});
it('should return false for review with MEDIUM severity findings', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('low', 'low-1'),
createTestFinding('medium', 'medium-1')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(false);
});
it('should return false for review with HIGH severity findings', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('low', 'low-1'),
createTestFinding('high', 'high-1')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(false);
});
it('should return false for review with CRITICAL severity findings', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('critical', 'critical-1')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(false);
});
it('should return false for failed review', () => {
const reviewResult = createReviewResult({
success: false,
findings: []
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(false);
});
});
describe('Post Clean Review Button Visibility', () => {
it('should show button when: review success, no findings selected, clean review, not posted, not request_changes', () => {
const reviewResult = createReviewResult({
findings: []
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
// Button visibility conditions
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(true);
});
it('should NOT show button when findings are selected', () => {
const reviewResult = createReviewResult({
findings: [createTestFinding('low')]
});
const selectedCount: number = 1; // Finding selected
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should NOT show button when review has MEDIUM severity findings', () => {
const reviewResult = createReviewResult({
findings: [createTestFinding('medium')]
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should NOT show button when findings have already been posted', () => {
const reviewResult = createReviewResult({
findings: []
});
const selectedCount = 0;
const hasPostedFindings = true; // Already posted
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should NOT show button when clean review has been posted', () => {
const reviewResult = createReviewResult({
findings: []
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = true; // Already posted clean review
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should NOT show button when overallStatus is request_changes', () => {
const reviewResult = createReviewResult({
findings: [],
overallStatus: 'request_changes'
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should NOT show button when review failed', () => {
const reviewResult = createReviewResult({
success: false,
findings: []
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
});
describe('Post Clean Review vs Post Findings Button Mutual Exclusivity', () => {
it('should show "Post Findings" button when findings are selected', () => {
const reviewResult = createReviewResult({
findings: [createTestFinding('low')]
});
const selectedCount: number = 1;
// Post Findings button: selectedCount > 0
const showPostFindings = selectedCount > 0;
// Post Clean Review button: selectedCount === 0 && other conditions
const showPostCleanReview =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(showPostFindings).toBe(true);
expect(showPostCleanReview).toBe(false);
});
it('should show "Post Clean Review" button when no findings are selected and review is clean', () => {
const reviewResult = createReviewResult({
findings: []
});
const selectedCount = 0;
const showPostFindings = selectedCount > 0;
const showPostCleanReview =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(showPostFindings).toBe(false);
expect(showPostCleanReview).toBe(true);
});
it('should show neither button when no findings exist and none selected but review is not clean', () => {
const reviewResult = createReviewResult({
findings: [createTestFinding('high')]
});
const selectedCount = 0;
const showPostFindings = selectedCount > 0;
const showPostCleanReview =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(showPostFindings).toBe(false);
expect(showPostCleanReview).toBe(false);
});
});
describe('Clean Review Comment Format', () => {
it('should format clean review comment correctly', () => {
const reviewResult = createReviewResult({
summary: 'All code passes review. No issues found.'
});
const cleanReviewMessage = `## ✅ Auto Claude PR Review - PASSED
**Status:** All code is good
${reviewResult.summary}
---
*This automated review found no issues. Generated by Auto Claude.*`;
expect(cleanReviewMessage).toContain('## ✅ Auto Claude PR Review - PASSED');
expect(cleanReviewMessage).toContain('**Status:** All code is good');
expect(cleanReviewMessage).toContain(reviewResult.summary);
expect(cleanReviewMessage).toContain('*This automated review found no issues. Generated by Auto Claude.*');
});
it('should include custom summary in clean review comment', () => {
const customSummary = 'Review completed: 5 files checked, 0 issues found. Code follows best practices.';
const reviewResult = createReviewResult({
summary: customSummary
});
const cleanReviewMessage = `## ✅ Auto Claude PR Review - PASSED
**Status:** All code is good
${reviewResult.summary}
---
*This automated review found no issues. Generated by Auto Claude.*`;
expect(cleanReviewMessage).toContain(customSummary);
});
it('should handle empty summary gracefully', () => {
const reviewResult = createReviewResult({
summary: ''
});
const cleanReviewMessage = `## ✅ Auto Claude PR Review - PASSED
**Status:** All code is good
${reviewResult.summary}
---
*This automated review found no issues. Generated by Auto Claude.*`;
expect(cleanReviewMessage).toBeDefined();
expect(cleanReviewMessage).toContain('All code is good');
});
});
describe('Follow-up Review Scenarios', () => {
it('should show clean review button for follow-up with all issues resolved', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
findings: [], // No new issues from follow-up
resolvedFindings: [createTestFinding('high', 'resolved-1')],
unresolvedFindings: [],
newFindingsSinceLastReview: []
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(true);
});
it('should not show clean review button for follow-up with unresolved HIGH issues', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
findings: [createTestFinding('high', 'unresolved-1')],
unresolvedFindings: [createTestFinding('high', 'unresolved-1')],
newFindingsSinceLastReview: []
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
it('should not show clean review button for follow-up with new issues found', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
findings: [createTestFinding('high', 'new-1')],
newFindingsSinceLastReview: [createTestFinding('high', 'new-1')]
});
const selectedCount = 0;
const hasPostedFindings = false;
const cleanReviewPosted = false;
const shouldShowButton =
selectedCount === 0 &&
reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
) &&
!hasPostedFindings &&
!cleanReviewPosted &&
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(false);
});
});
/**
* NOTE: These tests verify the STATE RESET ALGORITHM with plain JavaScript if-statements.
*
* The actual React useEffect hook behavior is tested in PRDetail.integration.test.tsx
* via component rerendering. These algorithm tests provide rapid feedback on the
* reset logic but don't verify the hook triggers correctly on PR changes.
*/
describe('State Reset on PR Change', () => {
it('should reset cleanReviewPosted state when pr.number changes', () => {
// Simplified logic test - actual useEffect behavior tested in integration tests
let prNumber = 123;
let cleanReviewPosted = true;
// Simulate the useEffect reset when pr.number changes
const currentPrNumber = prNumber;
const newPrNumber = 456;
if (currentPrNumber !== newPrNumber) {
cleanReviewPosted = false;
prNumber = newPrNumber;
}
expect(cleanReviewPosted).toBe(false);
expect(prNumber).toBe(456);
});
it('should not reset cleanReviewPosted state when pr.number stays the same', () => {
let prNumber = 123;
let cleanReviewPosted = true;
// Simulate no change in pr.number
const currentPrNumber = prNumber;
const newPrNumber = 123;
if (currentPrNumber !== newPrNumber) {
cleanReviewPosted = false;
prNumber = newPrNumber;
}
expect(cleanReviewPosted).toBe(true);
expect(prNumber).toBe(123);
});
});
describe('Success Message Display', () => {
it('should show clean review posted message when cleanReviewPosted is true', () => {
const postSuccess = null;
const cleanReviewPosted = true;
// From implementation: {cleanReviewPosted && !postSuccess && (...)}
const showCleanReviewMessage = cleanReviewPosted && !postSuccess;
// From implementation: {postSuccess && (...)}
const showPostFindingsMessage = !!postSuccess;
expect(showCleanReviewMessage).toBe(true);
expect(showPostFindingsMessage).toBe(false);
});
it('should show posted findings message when postSuccess is set', () => {
const postSuccess = { count: 3, timestamp: Date.now() };
const cleanReviewPosted = false;
const showCleanReviewMessage = cleanReviewPosted && !postSuccess;
const showPostFindingsMessage = !!postSuccess;
expect(showCleanReviewMessage).toBe(false);
expect(showPostFindingsMessage).toBe(true);
});
it('should prioritize posted findings message when both are set', () => {
const postSuccess = { count: 2, timestamp: Date.now() };
const cleanReviewPosted = true;
// Implementation: postSuccess takes priority (first condition checked)
const showCleanReviewMessage = cleanReviewPosted && !postSuccess;
const showPostFindingsMessage = !!postSuccess;
expect(showCleanReviewMessage).toBe(false);
expect(showPostFindingsMessage).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle review with only LOW findings as clean', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('low', 'low-1'),
createTestFinding('low', 'low-2')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(true);
expect(reviewResult.findings.length).toBe(2);
});
it('should handle mixed severity with LOW and MEDIUM as not clean', () => {
const reviewResult = createReviewResult({
findings: [
createTestFinding('low', 'low-1'),
createTestFinding('medium', 'medium-1')
]
});
const isCleanReview = reviewResult.success &&
!reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isCleanReview).toBe(false);
});
it('should handle overallStatus comment correctly', () => {
const reviewResult = createReviewResult({
overallStatus: 'comment',
findings: []
});
const shouldShowButton =
reviewResult.overallStatus !== 'request_changes';
expect(shouldShowButton).toBe(true);
});
});
});
@@ -0,0 +1,347 @@
/**
* Integration tests for PRDetail clean review state reset on PR change
* Tests that cleanReviewPosted state resets when pr.number changes
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../../../../shared/i18n';
import { PRDetail } from '../PRDetail';
import type { PRData, PRReviewResult } from '../../hooks/useGitHubPRs';
// Mock window.electronAPI
type PostCommentFn = (body: string) => void | Promise<void>;
const mockOnPostComment = vi.fn<PostCommentFn>();
const mockOnPostReview = vi.fn();
const mockOnRunReview = vi.fn();
const mockOnRunFollowupReview = vi.fn();
const mockOnCheckNewCommits = vi.fn();
const mockOnCancelReview = vi.fn();
const mockOnMergePR = vi.fn();
const mockOnAssignPR = vi.fn();
const mockOnGetLogs = vi.fn();
Object.defineProperty(window, 'electronAPI', {
value: {
github: {
getWorkflowsAwaitingApproval: vi.fn().mockResolvedValue({
awaiting_approval: 0,
workflow_runs: []
}),
checkMergeReadiness: vi.fn().mockResolvedValue({
blockers: []
})
}
}
});
// Create a mock PR data
function createMockPR(overrides: Partial<PRData> = {}): PRData {
return {
number: 123,
title: 'Test PR',
body: 'Test PR body',
state: 'open',
author: { login: 'testuser' },
headRefName: 'feature-branch',
baseRefName: 'main',
additions: 100,
deletions: 50,
changedFiles: 5,
assignees: [],
files: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
htmlUrl: 'https://github.com/test/repo/pull/123',
...overrides
};
}
// Create a mock clean review result
function createMockCleanReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
return {
prNumber: 123,
repo: 'test/repo',
success: true,
overallStatus: 'approve',
summary: 'All code passes review. No issues found.',
findings: [],
reviewedAt: '2024-01-01T00:00:00Z',
reviewedCommitSha: 'abc123',
...overrides
};
}
// Wrapper component for i18n
function I18nWrapper({ children }: { children: React.ReactNode }) {
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
describe('PRDetail - Clean Review State Reset Integration', () => {
const mockProjectId = 'test-project-id';
// Helper function to render PRDetail with common default props
function renderPRDetail(overrides: {
pr?: PRData;
reviewResult?: PRReviewResult;
onPostComment?: PostCommentFn;
} = {}) {
const defaultPR = createMockPR({ number: 123 });
const defaultReviewResult = createMockCleanReviewResult();
return render(
<I18nWrapper>
<PRDetail
pr={overrides.pr ?? defaultPR}
projectId={mockProjectId}
reviewResult={overrides.reviewResult ?? defaultReviewResult}
previousReviewResult={null}
reviewProgress={null}
startedAt={null}
isReviewing={false}
onRunReview={mockOnRunReview}
onRunFollowupReview={mockOnRunFollowupReview}
onCheckNewCommits={mockOnCheckNewCommits}
onCancelReview={mockOnCancelReview}
onPostReview={mockOnPostReview}
onPostComment={overrides.onPostComment ?? mockOnPostComment}
onMergePR={mockOnMergePR}
onAssignPR={mockOnAssignPR}
onGetLogs={mockOnGetLogs}
/>
</I18nWrapper>
);
}
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock return values
mockOnGetLogs.mockResolvedValue(null);
mockOnCheckNewCommits.mockResolvedValue({
hasNewCommits: false,
hasCommitsAfterPosting: false,
newCommitCount: 0
});
// Resolve successfully by default
mockOnPostComment.mockResolvedValue(undefined);
});
it('should reset cleanReviewPosted state when pr.number changes', async () => {
const initialPR = createMockPR({ number: 123 });
const cleanReviewResult = createMockCleanReviewResult();
const { rerender, unmount } = renderPRDetail({
pr: initialPR,
reviewResult: cleanReviewResult
});
// The "Post Clean Review" button should be visible initially
const postCleanReviewButton = screen.getByRole('button', { name: /post clean review/i });
expect(postCleanReviewButton).toBeInTheDocument();
// Click the button to post clean review
fireEvent.click(postCleanReviewButton);
// Wait for success message to appear (confirms cleanReviewPosted is true)
await waitFor(() => {
expect(screen.getByText(/clean review posted/i)).toBeInTheDocument();
});
// Button should be hidden after posting
expect(screen.queryByRole('button', { name: /post clean review/i })).not.toBeInTheDocument();
// Rerender with a different PR (number 456)
const differentPR = createMockPR({ number: 456 });
rerender(
<I18nWrapper>
<PRDetail
pr={differentPR}
projectId={mockProjectId}
reviewResult={cleanReviewResult}
previousReviewResult={null}
reviewProgress={null}
startedAt={null}
isReviewing={false}
onRunReview={mockOnRunReview}
onRunFollowupReview={mockOnRunFollowupReview}
onCheckNewCommits={mockOnCheckNewCommits}
onCancelReview={mockOnCancelReview}
onPostReview={mockOnPostReview}
onPostComment={mockOnPostComment}
onMergePR={mockOnMergePR}
onAssignPR={mockOnAssignPR}
onGetLogs={mockOnGetLogs}
/>
</I18nWrapper>
);
// After PR change, the "Post Clean Review" button should be visible again
// because cleanReviewPosted state was reset by useEffect when pr.number changed
const postCleanReviewButtonAfterChange = screen.queryByRole('button', { name: /post clean review/i });
expect(postCleanReviewButtonAfterChange).toBeInTheDocument();
unmount();
});
it('should show clean review success message after posting clean review', async () => {
const { unmount } = renderPRDetail();
// Initially, the success message should not be present
const successMessage = screen.queryByText(/clean review posted/i);
expect(successMessage).not.toBeInTheDocument();
// The "Post Clean Review" button should be visible
const postCleanReviewButton = screen.getByRole('button', { name: /post clean review/i });
expect(postCleanReviewButton).toBeInTheDocument();
// Click the button to post clean review
fireEvent.click(postCleanReviewButton);
// Wait for success message to appear
await waitFor(() => {
expect(screen.getByText(/clean review posted/i)).toBeInTheDocument();
});
// Button should be hidden after posting
expect(screen.queryByRole('button', { name: /post clean review/i })).not.toBeInTheDocument();
unmount();
});
it('should not show Post Clean Review button when review has HIGH severity findings', async () => {
const reviewWithHighFindings: PRReviewResult = {
prNumber: 123,
repo: 'test/repo',
success: true,
overallStatus: 'request_changes',
summary: 'Found high severity issues.',
reviewedAt: '2024-01-01T00:00:00Z',
findings: [
{
id: 'finding-1',
severity: 'high',
category: 'security',
title: 'Security Issue',
file: 'src/test.ts',
line: 10,
description: 'High severity issue',
fixable: true
}
],
reviewedCommitSha: 'abc123'
};
const { unmount } = renderPRDetail({ reviewResult: reviewWithHighFindings });
// The "Post Clean Review" button should NOT be visible for dirty reviews
const postCleanReviewButton = screen.queryByRole('button', { name: /post clean review/i });
expect(postCleanReviewButton).not.toBeInTheDocument();
unmount();
});
it('should show correct button state based on review cleanliness', async () => {
const cleanReviewResult = createMockCleanReviewResult();
const initialPR = createMockPR({ number: 123 });
// Test 1: Clean review (no findings)
const { rerender, unmount } = renderPRDetail({
pr: initialPR,
reviewResult: cleanReviewResult
});
// Clean review: Post Clean Review button should be visible
const postCleanReviewButton = screen.queryByRole('button', { name: /post clean review/i });
expect(postCleanReviewButton).toBeInTheDocument();
// Test 2: Dirty review (HIGH findings)
const dirtyReviewResult: PRReviewResult = {
prNumber: 123,
repo: 'test/repo',
success: true,
overallStatus: 'request_changes',
summary: 'Found issues.',
reviewedAt: '2024-01-01T00:00:00Z',
findings: [
{
id: 'finding-1',
severity: 'high',
category: 'security',
title: 'Security Issue',
file: 'src/test.ts',
line: 10,
description: 'High severity issue',
fixable: true
}
],
reviewedCommitSha: 'abc123'
};
rerender(
<I18nWrapper>
<PRDetail
pr={initialPR}
projectId={mockProjectId}
reviewResult={dirtyReviewResult}
previousReviewResult={null}
reviewProgress={null}
startedAt={null}
isReviewing={false}
onRunReview={mockOnRunReview}
onRunFollowupReview={mockOnRunFollowupReview}
onCheckNewCommits={mockOnCheckNewCommits}
onCancelReview={mockOnCancelReview}
onPostReview={mockOnPostReview}
onPostComment={mockOnPostComment}
onMergePR={mockOnMergePR}
onAssignPR={mockOnAssignPR}
onGetLogs={mockOnGetLogs}
/>
</I18nWrapper>
);
// Dirty review: Post Clean Review button should NOT be visible
const postCleanReviewButtonDirty = screen.queryByRole('button', { name: /post clean review/i });
expect(postCleanReviewButtonDirty).not.toBeInTheDocument();
unmount();
});
it('should show error message when posting clean review fails', async () => {
// Mock onPostComment to reject
const testError = new Error('Failed to post comment: Rate limit exceeded');
mockOnPostComment.mockRejectedValue(testError);
const { unmount } = renderPRDetail({
onPostComment: mockOnPostComment
});
// The "Post Clean Review" button should be visible initially
const postCleanReviewButton = screen.getByRole('button', { name: /post clean review/i });
expect(postCleanReviewButton).toBeInTheDocument();
// Click the button to attempt posting clean review
fireEvent.click(postCleanReviewButton);
// Wait for normalized error message to appear (shows friendly message, not raw error)
await waitFor(() => {
expect(screen.getByText(/Failed to post clean review/i)).toBeInTheDocument();
});
// "View details" button should be available
await waitFor(() => {
expect(screen.getByText(/View details/i)).toBeInTheDocument();
});
// Button should still be visible for retry after error
expect(screen.queryByRole('button', { name: /post clean review/i })).toBeInTheDocument();
// Success message should NOT be shown
expect(screen.queryByText(/clean review posted/i)).not.toBeInTheDocument();
unmount();
});
});
@@ -30,9 +30,9 @@ interface UseGitHubPRsResult {
selectedPRNumber: number | null;
reviewResult: PRReviewResult | null;
reviewProgress: PRReviewProgress | null;
startedAt: string | null;
isReviewing: boolean;
previousReviewResult: PRReviewResult | null;
startedAt: string | null;
isConnected: boolean;
repoFullName: string | null;
activePRReviews: number[]; // PR numbers currently being reviewed
@@ -506,9 +506,9 @@ export function useGitHubPRs(
selectedPRNumber,
reviewResult,
reviewProgress,
startedAt,
isReviewing,
previousReviewResult,
startedAt,
isConnected,
repoFullName,
activePRReviews,
@@ -129,7 +129,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
prNumber: result.prNumber,
projectId,
isReviewing: false,
startedAt: null,
startedAt: existing?.startedAt ?? null,
progress: null,
result,
previousResult: existing?.previousResult ?? null,
@@ -152,7 +152,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
prNumber,
projectId,
isReviewing: false,
startedAt: null,
startedAt: existing?.startedAt ?? null,
progress: null,
result: existing?.result ?? null,
previousResult: existing?.previousResult ?? null,
@@ -294,7 +294,16 @@
"workflowsAwaitingDescription": "This PR is from a fork and requires workflow approval before CI checks can run. Approve the workflows to continue.",
"viewOnGitHub": "View",
"approveWorkflow": "Approve",
"approveAllWorkflows": "Approve All Workflows"
"approveAllWorkflows": "Approve All Workflows",
"postCleanReview": "Post Clean Review",
"postingCleanReview": "Posting...",
"cleanReviewPosted": "Clean review posted",
"cleanReviewMessageTitle": "## ✅ Auto Claude PR Review - PASSED",
"cleanReviewMessageStatus": "**Status:** All code is good",
"cleanReviewMessageFooter": "*This automated review found no issues. Generated by Auto Claude.*",
"failedPostCleanReview": "Failed to post clean review",
"viewErrorDetails": "View details",
"hideErrorDetails": "Hide details"
},
"downloads": {
"toggleExpand": "Toggle download details",
@@ -294,7 +294,16 @@
"workflowsAwaitingDescription": "Cette PR provient d'un fork et nécessite l'approbation des workflows avant que les vérifications CI puissent s'exécuter. Approuvez les workflows pour continuer.",
"viewOnGitHub": "Voir",
"approveWorkflow": "Approuver",
"approveAllWorkflows": "Approuver tous les workflows"
"approveAllWorkflows": "Approuver tous les workflows",
"postCleanReview": "Publier révision propre",
"postingCleanReview": "Publication...",
"cleanReviewPosted": "Révision propre publiée",
"cleanReviewMessageTitle": "## ✅ Auto Claude PR Review - PASSED",
"cleanReviewMessageStatus": "**Status:** All code is good",
"cleanReviewMessageFooter": "*This automated review found no issues. Generated by Auto Claude.*",
"failedPostCleanReview": "Échec de la publication de la révision",
"viewErrorDetails": "Voir les détails",
"hideErrorDetails": "Masquer les détails"
},
"downloads": {
"toggleExpand": "Afficher/masquer les détails",