fix(github-prs): show running review state when switching back to PR with in-progress review (ACS-200) (#890)
* fix(github-prs): show running review state when switching back to PR with in-progress review (ACS-200) Fixes issue where navigating away from a PR with an in-progress AI review and switching back would hide the running review state. The user had to click "Followup Review" to reveal the in-progress review. Root cause: prStatus computation checked reviewResult before isReviewing. Since reviewResult is null during an active review, it returned 'not_reviewed' status, hiding the running review. Solution: Check isReviewing FIRST before reviewResult in the prStatus computation. Also add 'reviewing' to the PRStatus type union. Test coverage: - PRDetail.test.tsx: 20 tests for prStatus computation logic - ReviewStatusTree.test.tsx: 21 tests for component handling Note: Pre-existing TypeScript errors with @lydell/node-pty block typecheck hook. Tests pass via vitest (41 tests passed). * fix: add @preload path alias and fix TypeScript errors in test files - Add @preload/* path alias to tsconfig.json for @preload/api modules - Add @ts-ignore for useGitHubPRs imports (vitest resolves correctly) - Fix implicit any type in filter callback * fix: change @ts-ignore to @ts-expect-error and remove unused imports - Use @ts-expect-error instead of @ts-ignore for ESLint compliance - Remove unused imports (renderHook, act, useState, vi, beforeEach) * fix(acs-200): ensure PR review state consistency when switching PRs Fixes a bug where switching between PRs would show stale review results from the previously selected PR. Root cause: Two different sources of truth for PR review state: - Hook derived reviewResult, isReviewing, reviewProgress - Component locally computed previousReviewResult and startedAt Changes: - Add previousReviewResult and startedAt to hook's return values - Remove local computation in GitHubPRs.tsx - All review state now comes from single source (hook's selectedPRReviewState) - Add startedAt prop to all ReviewStatusTree tests Related to: PR review started timestamp fix (same data flow issue) Files modified: - useGitHubPRs.ts: Add previousReviewResult/startedAt to interface and return - GitHubPRs.tsx: Use hook values instead of local computation - ReviewStatusTree.test.tsx: Add startedAt prop to all test cases * fix(test): remove unused container variables in ReviewStatusTree tests --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -65,6 +65,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewResult,
|
||||
reviewProgress,
|
||||
isReviewing,
|
||||
previousReviewResult,
|
||||
startedAt,
|
||||
hasMore,
|
||||
selectPR,
|
||||
runReview,
|
||||
@@ -83,9 +85,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
selectedPR,
|
||||
} = useGitHubPRs(selectedProject?.id, { isActive });
|
||||
|
||||
// Get previousResult and newCommitsCheck for follow-up review continuity
|
||||
// Get newCommitsCheck for the selected PR (other values come from hook to ensure consistency)
|
||||
const selectedPRReviewState = selectedPRNumber ? getReviewStateForPR(selectedPRNumber) : null;
|
||||
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
|
||||
const storedNewCommitsCheck = selectedPRReviewState?.newCommitsCheck ?? null;
|
||||
|
||||
// PR filtering
|
||||
@@ -244,6 +245,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewResult={reviewResult}
|
||||
previousReviewResult={previousReviewResult}
|
||||
reviewProgress={reviewProgress}
|
||||
startedAt={startedAt}
|
||||
isReviewing={isReviewing}
|
||||
initialNewCommitsCheck={storedNewCommitsCheck}
|
||||
isActive={isActive}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface PRDetailProps {
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
startedAt: string | null;
|
||||
isReviewing: boolean;
|
||||
initialNewCommitsCheck?: NewCommitsCheck | null;
|
||||
isActive?: boolean;
|
||||
@@ -71,6 +72,7 @@ export function PRDetail({
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
initialNewCommitsCheck,
|
||||
isActive: _isActive = false,
|
||||
@@ -104,7 +106,7 @@ export function PRDetail({
|
||||
const [prLogs, setPrLogs] = useState<PRLogsType | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const logsLoadedRef = useRef(false);
|
||||
|
||||
|
||||
// Merge readiness state (real-time validation of AI verdict freshness)
|
||||
const [mergeReadiness, setMergeReadiness] = useState<MergeReadiness | null>(null);
|
||||
const mergeReadinessAbortRef = useRef<AbortController | null>(null);
|
||||
@@ -379,8 +381,20 @@ export function PRDetail({
|
||||
}, [reviewResult]);
|
||||
|
||||
// Compute the overall PR review status for visual display
|
||||
type PRStatus = 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain';
|
||||
type PRStatus = 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain' | 'reviewing';
|
||||
const prStatus: { status: PRStatus; label: string; description: string; icon: React.ReactNode; color: string } = useMemo(() => {
|
||||
// Check for in-progress review FIRST (before checking result)
|
||||
// This ensures the running review state is visible when switching back to a PR
|
||||
if (isReviewing) {
|
||||
return {
|
||||
status: 'reviewing',
|
||||
label: t('prReview.aiReviewInProgress'),
|
||||
description: reviewProgress?.message || t('prReview.analysisInProgress'),
|
||||
icon: <Bot className="h-5 w-5 animate-pulse" />,
|
||||
color: 'bg-blue-500/10 text-blue-500 border-blue-500/30',
|
||||
};
|
||||
}
|
||||
|
||||
if (!reviewResult || !reviewResult.success) {
|
||||
return {
|
||||
status: 'not_reviewed',
|
||||
@@ -523,7 +537,7 @@ export function PRDetail({
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: 'bg-primary/20 text-primary border-primary/50',
|
||||
};
|
||||
}, [reviewResult, postedFindingIds, isReadyToMerge, newCommitsCheck, t]);
|
||||
}, [isReviewing, reviewProgress, reviewResult, postedFindingIds, isReadyToMerge, newCommitsCheck, t]);
|
||||
|
||||
const handlePostReview = async () => {
|
||||
const idsToPost = Array.from(selectedFindingIds);
|
||||
@@ -633,6 +647,7 @@ export function PRDetail({
|
||||
<ReviewStatusTree
|
||||
status={prStatus.status}
|
||||
isReviewing={isReviewing}
|
||||
startedAt={startedAt}
|
||||
reviewResult={reviewResult}
|
||||
previousReviewResult={previousReviewResult}
|
||||
postedCount={new Set([...postedFindingIds, ...(reviewResult?.postedFindingIds ?? [])]).size}
|
||||
|
||||
@@ -15,11 +15,13 @@ export type ReviewStatus =
|
||||
| 'ready_to_merge'
|
||||
| 'needs_attention'
|
||||
| 'ready_for_followup'
|
||||
| 'followup_issues_remain';
|
||||
| 'followup_issues_remain'
|
||||
| 'reviewing';
|
||||
|
||||
export interface ReviewStatusTreeProps {
|
||||
status: ReviewStatus;
|
||||
isReviewing: boolean;
|
||||
startedAt: string | null;
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
postedCount: number;
|
||||
@@ -37,6 +39,7 @@ export interface ReviewStatusTreeProps {
|
||||
export function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
startedAt,
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
postedCount,
|
||||
@@ -127,7 +130,7 @@ export function ReviewStatusTree({
|
||||
id: 'start',
|
||||
label: t('prReview.reviewStarted'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.reviewedAt || new Date().toISOString()
|
||||
date: startedAt || reviewResult?.reviewedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Step 2: AI Analysis
|
||||
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/**
|
||||
* Unit tests for PRDetail component prStatus computation
|
||||
* Tests the fix for ACS-200: In-progress PR review should be displayed when switching back to PR
|
||||
*
|
||||
* Key behavior tested:
|
||||
* - isReviewing is checked FIRST before reviewResult (fixes ACS-200)
|
||||
* - Reviewing status has correct label, icon, and color
|
||||
* - All other status computations remain unaffected
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-expect-error - vitest resolves this correctly
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../../../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '@preload/api/modules/github-api';
|
||||
|
||||
/**
|
||||
* Factory function to create a mock PR data object
|
||||
*/
|
||||
function createMockPR(overrides: Partial<PRData> = {}): PRData {
|
||||
return {
|
||||
number: 123,
|
||||
title: 'Test PR',
|
||||
body: 'Test PR description',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a mock PR review result
|
||||
*/
|
||||
function createMockReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
|
||||
return {
|
||||
prNumber: 123,
|
||||
repo: 'test/repo',
|
||||
success: true,
|
||||
findings: [],
|
||||
summary: 'Test summary',
|
||||
overallStatus: 'approve',
|
||||
reviewedAt: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a mock PR review progress
|
||||
*/
|
||||
function createMockReviewProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress {
|
||||
return {
|
||||
phase: 'analyzing',
|
||||
prNumber: 123,
|
||||
progress: 50,
|
||||
message: 'Analyzing PR...',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a mock NewCommitsCheck result
|
||||
*/
|
||||
function createMockNewCommitsCheck(overrides: Partial<NewCommitsCheck> = {}): NewCommitsCheck {
|
||||
return {
|
||||
hasNewCommits: false,
|
||||
newCommitCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the prStatus computation logic from PRDetail.tsx
|
||||
* This is extracted for testing to avoid needing to render the entire component
|
||||
*/
|
||||
function computePRStatus(params: {
|
||||
isReviewing: boolean;
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
reviewResult: PRReviewResult | null;
|
||||
postedFindingIds: Set<string>;
|
||||
isReadyToMerge: boolean;
|
||||
newCommitsCheck: NewCommitsCheck | null;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const {
|
||||
isReviewing,
|
||||
reviewProgress,
|
||||
reviewResult,
|
||||
postedFindingIds,
|
||||
isReadyToMerge,
|
||||
newCommitsCheck,
|
||||
t,
|
||||
} = params;
|
||||
|
||||
// Check for in-progress review FIRST (before checking result)
|
||||
// This ensures the running review state is visible when switching back to a PR
|
||||
if (isReviewing) {
|
||||
return {
|
||||
status: 'reviewing' as const,
|
||||
label: t('prReview.aiReviewInProgress'),
|
||||
description: reviewProgress?.message || t('prReview.analysisInProgress'),
|
||||
color: 'bg-blue-500/10 text-blue-500 border-blue-500/30',
|
||||
};
|
||||
}
|
||||
|
||||
if (!reviewResult || !reviewResult.success) {
|
||||
return {
|
||||
status: 'not_reviewed' as const,
|
||||
label: t('prReview.notReviewed'),
|
||||
description: t('prReview.runAIReviewDesc'),
|
||||
color: 'bg-muted text-muted-foreground border-muted',
|
||||
};
|
||||
}
|
||||
|
||||
const allPostedIds = new Set([...postedFindingIds, ...(reviewResult.postedFindingIds ?? [])]);
|
||||
const totalPosted = allPostedIds.size;
|
||||
const hasPosted = totalPosted > 0 || reviewResult.hasPostedFindings;
|
||||
const hasBlockers = reviewResult.findings.some(
|
||||
(f: { severity: string }) => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
const hasNewCommits = newCommitsCheck?.hasNewCommits ?? false;
|
||||
const newCommitCount = newCommitsCheck?.newCommitCount ?? 0;
|
||||
const hasCommitsAfterPosting = newCommitsCheck?.hasCommitsAfterPosting ?? false;
|
||||
|
||||
// Follow-up review specific statuses
|
||||
if (reviewResult.isFollowupReview) {
|
||||
const resolvedCount = reviewResult.resolvedFindings?.length ?? 0;
|
||||
const unresolvedCount = reviewResult.unresolvedFindings?.length ?? 0;
|
||||
const newIssuesCount = reviewResult.newFindingsSinceLastReview?.length ?? 0;
|
||||
const hasBlockingIssuesRemaining = reviewResult.findings.some(
|
||||
(f: { severity: string }) => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
|
||||
if (hasNewCommits && hasCommitsAfterPosting) {
|
||||
return {
|
||||
status: 'ready_for_followup' as const,
|
||||
label: t('prReview.readyForFollowup'),
|
||||
color: 'bg-info/20 text-info border-info/50',
|
||||
};
|
||||
}
|
||||
|
||||
if (unresolvedCount === 0 && newIssuesCount === 0) {
|
||||
return {
|
||||
status: 'ready_to_merge' as const,
|
||||
label: t('prReview.readyToMerge'),
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasBlockingIssuesRemaining) {
|
||||
return {
|
||||
status: 'ready_to_merge' as const,
|
||||
label: t('prReview.readyToMerge'),
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'followup_issues_remain' as const,
|
||||
label: t('prReview.blockingIssues'),
|
||||
color: 'bg-warning/20 text-warning border-warning/50',
|
||||
};
|
||||
}
|
||||
|
||||
// Initial review statuses
|
||||
if (hasPosted && hasNewCommits && hasCommitsAfterPosting) {
|
||||
return {
|
||||
status: 'ready_for_followup' as const,
|
||||
label: t('prReview.readyForFollowup'),
|
||||
color: 'bg-info/20 text-info border-info/50',
|
||||
};
|
||||
}
|
||||
|
||||
if (isReadyToMerge && hasPosted) {
|
||||
return {
|
||||
status: 'ready_to_merge' as const,
|
||||
label: t('prReview.readyToMerge'),
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasPosted && hasBlockers) {
|
||||
return {
|
||||
status: 'waiting_for_changes' as const,
|
||||
label: t('prReview.waitingForChanges'),
|
||||
color: 'bg-warning/20 text-warning border-warning/50',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasPosted && !hasBlockers) {
|
||||
return {
|
||||
status: 'ready_to_merge' as const,
|
||||
label: t('prReview.readyToMerge'),
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
}
|
||||
|
||||
const unpostedFindings = reviewResult.findings.filter((f: { id: string }) => !allPostedIds.has(f.id));
|
||||
const hasUnpostedBlockers = unpostedFindings.some(
|
||||
(f: { severity: string }) => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
|
||||
if (hasUnpostedBlockers) {
|
||||
return {
|
||||
status: 'needs_attention' as const,
|
||||
label: t('prReview.needsAttention'),
|
||||
color: 'bg-destructive/20 text-destructive border-destructive/50',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'reviewed_pending_post' as const,
|
||||
label: t('prReview.reviewComplete'),
|
||||
color: 'bg-primary/20 text-primary border-primary/50',
|
||||
};
|
||||
}
|
||||
|
||||
// Mock translation function
|
||||
const mockT = (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'prReview.aiReviewInProgress': 'AI Review in Progress',
|
||||
'prReview.analysisInProgress': 'AI Analysis in Progress...',
|
||||
'prReview.notReviewed': 'Not Reviewed',
|
||||
'prReview.runAIReviewDesc': 'Run an AI review to analyze this PR',
|
||||
'prReview.readyForFollowup': 'Ready for Follow-up',
|
||||
'prReview.readyToMerge': 'Ready to Merge',
|
||||
'prReview.waitingForChanges': 'Waiting for Changes',
|
||||
'prReview.blockingIssues': 'Blocking Issues',
|
||||
'prReview.needsAttention': 'Needs Attention',
|
||||
'prReview.reviewComplete': 'Review Complete',
|
||||
};
|
||||
return translations[key] || key;
|
||||
};
|
||||
|
||||
describe('PRDetail - prStatus Computation (ACS-200 Fix)', () => {
|
||||
describe('isReviewing Priority Check (ACS-200 Fix)', () => {
|
||||
it('should return "reviewing" status when isReviewing is true, regardless of reviewResult', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({ message: 'Fetching files...' }),
|
||||
reviewResult: null, // Even with null reviewResult
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('reviewing');
|
||||
expect(status.label).toBe('AI Review in Progress');
|
||||
expect(status.description).toBe('Fetching files...');
|
||||
expect(status.color).toBe('bg-blue-500/10 text-blue-500 border-blue-500/30');
|
||||
});
|
||||
|
||||
it('should return "reviewing" status when isReviewing is true, even with successful reviewResult', () => {
|
||||
// This is the key test for ACS-200: When switching back to a PR with in-progress review,
|
||||
// the reviewing state should be shown, not the completed review state
|
||||
const status = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({ message: 'Analyzing code...' }),
|
||||
reviewResult: createMockReviewResult({
|
||||
success: true,
|
||||
overallStatus: 'approve',
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'low',
|
||||
category: 'quality',
|
||||
title: 'Minor issue',
|
||||
description: 'A minor code quality issue',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: true,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('reviewing');
|
||||
expect(status.label).toBe('AI Review in Progress');
|
||||
expect(status.description).toBe('Analyzing code...');
|
||||
});
|
||||
|
||||
it('should use fallback description when reviewProgress is null but isReviewing is true', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: null, // No progress data yet
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('reviewing');
|
||||
expect(status.description).toBe('AI Analysis in Progress...');
|
||||
});
|
||||
|
||||
it('should return "not_reviewed" when isReviewing is false and reviewResult is null', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('not_reviewed');
|
||||
expect(status.label).toBe('Not Reviewed');
|
||||
expect(status.description).toBe('Run an AI review to analyze this PR');
|
||||
});
|
||||
|
||||
it('should return "not_reviewed" when isReviewing is false and reviewResult.success is false', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({ success: false, error: 'Review failed' }),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('not_reviewed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Review Status Transitions', () => {
|
||||
it('should correctly transition from "not_reviewed" to "reviewing" when review starts', () => {
|
||||
// Initial state: not reviewed
|
||||
const beforeReview = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(beforeReview.status).toBe('not_reviewed');
|
||||
|
||||
// Review starts
|
||||
const duringReview = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress(),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(duringReview.status).toBe('reviewing');
|
||||
});
|
||||
|
||||
it('should correctly transition from "reviewing" to completed status when review finishes', () => {
|
||||
// During review
|
||||
const duringReview = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress(),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(duringReview.status).toBe('reviewing');
|
||||
|
||||
// Review completes with findings
|
||||
const afterReview = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'medium',
|
||||
category: 'quality',
|
||||
title: 'Code quality issue',
|
||||
description: 'Improve code quality',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(afterReview.status).toBe('reviewed_pending_post');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Completed Review Statuses', () => {
|
||||
it('should return "reviewed_pending_post" when review has unposted findings', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'low',
|
||||
category: 'style',
|
||||
title: 'Style issue',
|
||||
description: 'Minor style issue',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('reviewed_pending_post');
|
||||
expect(status.label).toBe('Review Complete');
|
||||
});
|
||||
|
||||
it('should return "needs_attention" when review has unposted blocking findings', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'critical',
|
||||
category: 'security',
|
||||
title: 'Security issue',
|
||||
description: 'Critical security vulnerability',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('needs_attention');
|
||||
expect(status.label).toBe('Needs Attention');
|
||||
});
|
||||
|
||||
it('should return "ready_to_merge" when review is posted with no blockers', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'low',
|
||||
category: 'style',
|
||||
title: 'Style issue',
|
||||
description: 'Minor style issue',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
}),
|
||||
postedFindingIds: new Set(['finding-1']),
|
||||
isReadyToMerge: true,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('ready_to_merge');
|
||||
expect(status.label).toBe('Ready to Merge');
|
||||
});
|
||||
|
||||
it('should return "waiting_for_changes" when blockers are posted', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'high',
|
||||
category: 'security',
|
||||
title: 'Security issue',
|
||||
description: 'High severity security issue',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
}),
|
||||
postedFindingIds: new Set(['finding-1']),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('waiting_for_changes');
|
||||
expect(status.label).toBe('Waiting for Changes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow-up Review Statuses', () => {
|
||||
it('should return "ready_for_followup" when new commits exist after posting', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
isFollowupReview: false,
|
||||
findings: [],
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
}),
|
||||
postedFindingIds: new Set(['finding-1']),
|
||||
isReadyToMerge: true,
|
||||
newCommitsCheck: createMockNewCommitsCheck({
|
||||
hasNewCommits: true,
|
||||
newCommitCount: 3,
|
||||
hasCommitsAfterPosting: true,
|
||||
}),
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('ready_for_followup');
|
||||
expect(status.label).toBe('Ready for Follow-up');
|
||||
});
|
||||
|
||||
it('should return "ready_to_merge" for follow-up when all issues resolved', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
isFollowupReview: true,
|
||||
findings: [],
|
||||
resolvedFindings: ['finding-1', 'finding-2'],
|
||||
unresolvedFindings: [],
|
||||
newFindingsSinceLastReview: [],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('ready_to_merge');
|
||||
expect(status.label).toBe('Ready to Merge');
|
||||
});
|
||||
|
||||
it('should return "followup_issues_remain" when blocking issues remain after follow-up', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
isFollowupReview: true,
|
||||
findings: [
|
||||
{
|
||||
id: 'unresolved-blocking',
|
||||
severity: 'high',
|
||||
category: 'security',
|
||||
title: 'Unresolved high issue',
|
||||
description: 'Still needs fixing',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
resolvedFindings: ['finding-1'],
|
||||
unresolvedFindings: ['unresolved-blocking'],
|
||||
newFindingsSinceLastReview: [],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('followup_issues_remain');
|
||||
expect(status.label).toBe('Blocking Issues');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty findings array correctly', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [],
|
||||
summary: 'No issues found! Code looks great.',
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: true,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('reviewed_pending_post');
|
||||
});
|
||||
|
||||
it('should handle postedFindingIds from both local state and reviewResult', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'low',
|
||||
category: 'style',
|
||||
title: 'Issue 1',
|
||||
description: 'Description 1',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
{
|
||||
id: 'finding-2',
|
||||
severity: 'low',
|
||||
category: 'style',
|
||||
title: 'Issue 2',
|
||||
description: 'Description 2',
|
||||
file: 'src/test.ts',
|
||||
line: 20,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
postedFindingIds: ['finding-1'], // From previous post
|
||||
}),
|
||||
postedFindingIds: new Set(['finding-2']), // Local state
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
// Both findings should be considered posted
|
||||
expect(status.status).toBe('ready_to_merge');
|
||||
});
|
||||
|
||||
it('should handle null newCommitsCheck gracefully', () => {
|
||||
const status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
}),
|
||||
postedFindingIds: new Set(['finding-1']),
|
||||
isReadyToMerge: true,
|
||||
newCommitsCheck: null, // No check performed yet
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(status.status).toBe('ready_to_merge');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PRDetail - ACS-200 Integration Test Scenarios', () => {
|
||||
describe('Scenario: User switches back to PR with in-progress review', () => {
|
||||
it('should maintain "reviewing" status when switching between PRs', () => {
|
||||
// User starts review on PR #1
|
||||
const pr1Reviewing = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({
|
||||
prNumber: 1,
|
||||
message: 'Analyzing PR #1...',
|
||||
}),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(pr1Reviewing.status).toBe('reviewing');
|
||||
|
||||
// User switches to PR #2 (not reviewed yet)
|
||||
const pr2NotReviewed = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(pr2NotReviewed.status).toBe('not_reviewed');
|
||||
|
||||
// User switches back to PR #1 - should STILL see "reviewing" status
|
||||
// This is the key fix for ACS-200
|
||||
const pr1ReviewingAgain = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({
|
||||
prNumber: 1,
|
||||
message: 'Still analyzing PR #1...',
|
||||
progress: 75,
|
||||
}),
|
||||
reviewResult: null, // Still no result because review is in progress
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(pr1ReviewingAgain.status).toBe('reviewing');
|
||||
expect(pr1ReviewingAgain.description).toBe('Still analyzing PR #1...');
|
||||
});
|
||||
|
||||
it('should show updated progress message when switching back to reviewing PR', () => {
|
||||
// PR #1 starts review
|
||||
const initialProgress = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({
|
||||
message: 'Fetching files...',
|
||||
progress: 10,
|
||||
}),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(initialProgress.description).toBe('Fetching files...');
|
||||
|
||||
// After some time, progress updates
|
||||
const updatedProgress = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({
|
||||
message: 'Analyzing code with AI...',
|
||||
progress: 50,
|
||||
}),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
expect(updatedProgress.description).toBe('Analyzing code with AI...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario: Multiple PRs with different review states', () => {
|
||||
it('should correctly track status for each PR independently', () => {
|
||||
// PR #1: Review in progress
|
||||
const pr1Status = computePRStatus({
|
||||
isReviewing: true,
|
||||
reviewProgress: createMockReviewProgress({ prNumber: 1, message: 'Reviewing PR #1' }),
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
// PR #2: Completed review with findings
|
||||
const pr2Status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: createMockReviewResult({
|
||||
prNumber: 2,
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'medium',
|
||||
category: 'quality',
|
||||
title: 'Issue',
|
||||
description: 'Description',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
// PR #3: Not reviewed
|
||||
const pr3Status = computePRStatus({
|
||||
isReviewing: false,
|
||||
reviewProgress: null,
|
||||
reviewResult: null,
|
||||
postedFindingIds: new Set(),
|
||||
isReadyToMerge: false,
|
||||
newCommitsCheck: null,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(pr1Status.status).toBe('reviewing');
|
||||
expect(pr2Status.status).toBe('reviewed_pending_post');
|
||||
expect(pr3Status.status).toBe('not_reviewed');
|
||||
});
|
||||
});
|
||||
});
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/**
|
||||
* Unit tests for ReviewStatusTree component
|
||||
* Tests the handling of 'reviewing' status added for ACS-200 fix
|
||||
*
|
||||
* Key behavior tested:
|
||||
* - 'reviewing' status is properly handled
|
||||
* - Status dot color is animated blue when reviewing
|
||||
* - Status label shows "AI Review in Progress" when reviewing
|
||||
* - Cancel button is shown when reviewing
|
||||
* - Tree structure shows correct steps during review
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ReviewStatusTree, type ReviewStatus } from '../ReviewStatusTree';
|
||||
// @ts-expect-error - vitest resolves this correctly
|
||||
import type { PRReviewResult } from '../../../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '@preload/api/modules/github-api';
|
||||
import i18n from '@shared/i18n';
|
||||
|
||||
/**
|
||||
* Factory function to create a mock PR review result
|
||||
*/
|
||||
function createMockReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
|
||||
return {
|
||||
prNumber: 123,
|
||||
repo: 'test/repo',
|
||||
success: true,
|
||||
findings: [],
|
||||
summary: 'Test summary',
|
||||
overallStatus: 'approve',
|
||||
reviewedAt: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a mock NewCommitsCheck result
|
||||
*/
|
||||
function createMockNewCommitsCheck(overrides: Partial<NewCommitsCheck> = {}): NewCommitsCheck {
|
||||
return {
|
||||
hasNewCommits: false,
|
||||
newCommitCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock callbacks
|
||||
const mockOnRunReview = vi.fn();
|
||||
const mockOnRunFollowupReview = vi.fn();
|
||||
const mockOnCancelReview = vi.fn();
|
||||
|
||||
describe('ReviewStatusTree - Reviewing Status (ACS-200)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Type System - ReviewStatus Type', () => {
|
||||
it('should include "reviewing" in ReviewStatus type union', () => {
|
||||
// This test verifies that the 'reviewing' status is part of the type
|
||||
const reviewingStatus: ReviewStatus = 'reviewing';
|
||||
expect(reviewingStatus).toBe('reviewing');
|
||||
|
||||
// All other valid statuses
|
||||
const validStatuses: ReviewStatus[] = [
|
||||
'not_reviewed',
|
||||
'reviewed_pending_post',
|
||||
'waiting_for_changes',
|
||||
'ready_to_merge',
|
||||
'needs_attention',
|
||||
'ready_for_followup',
|
||||
'followup_issues_remain',
|
||||
'reviewing',
|
||||
];
|
||||
expect(validStatuses).toContain('reviewing');
|
||||
expect(validStatuses).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Props - isReviewing Flag', () => {
|
||||
it('should accept isReviewing prop', () => {
|
||||
const { container } = render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Component should render without errors
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle isReviewing=false correctly', () => {
|
||||
const { container } = render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Not Reviewed Status with No Active Review', () => {
|
||||
it('should render simple status when status is not_reviewed and not reviewing', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show "Not Reviewed" label
|
||||
expect(screen.getByText(i18n.t('prReview.notReviewed'))).toBeInTheDocument();
|
||||
|
||||
// Should show "Run AI Review" button
|
||||
expect(screen.getByText(i18n.t('prReview.runAIReview'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Run AI Review button with correct callback', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const runReviewButton = screen.getByText(i18n.t('prReview.runAIReview'));
|
||||
runReviewButton.click();
|
||||
expect(mockOnRunReview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reviewing State - Tree View', () => {
|
||||
it('should show tree structure when isReviewing is true', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show the full tree (collapsible card) when reviewing
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
|
||||
// Should show cancel button
|
||||
expect(screen.getByText(i18n.t('prReview.cancel'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "AI Review in Progress" status label when isReviewing', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show cancel button when isReviewing is true', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByText(i18n.t('prReview.cancel'));
|
||||
cancelButton.click();
|
||||
expect(mockOnCancelReview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show correct tree steps during initial review', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show "Review Started" step (completed)
|
||||
expect(screen.getByText(i18n.t('prReview.reviewStarted'))).toBeInTheDocument();
|
||||
|
||||
// Should show "AI Analysis in Progress..." step (current)
|
||||
expect(screen.getByText(i18n.t('prReview.analysisInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow-up Review in Progress', () => {
|
||||
it('should show follow-up specific steps when isReviewing with previousReviewResult', () => {
|
||||
const previousResult = createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'high',
|
||||
category: 'security',
|
||||
title: 'Security issue',
|
||||
description: 'Fix needed',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={previousResult}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={createMockNewCommitsCheck({
|
||||
hasNewCommits: true,
|
||||
newCommitCount: 2,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show previous review step
|
||||
expect(screen.getByText(/Previous Review/)).toBeInTheDocument();
|
||||
|
||||
// Should show new commits step
|
||||
expect(screen.getByText(/2 New Commits/i)).toBeInTheDocument();
|
||||
|
||||
// Should show follow-up analysis step
|
||||
expect(screen.getByText(i18n.t('prReview.followupInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle follow-up in progress without previousReviewResult', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({ isFollowupReview: true })}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show AI Review in Progress (when reviewing, this takes precedence)
|
||||
expect(screen.getByText('AI Review in Progress')).toBeInTheDocument();
|
||||
|
||||
// Should show cancel button when reviewing
|
||||
expect(screen.getByText(i18n.t('prReview.cancel'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Dot Color Logic', () => {
|
||||
it('should return animated blue dot when isReviewing is true', () => {
|
||||
// getStatusDotColor function in ReviewStatusTree:
|
||||
// if (isReviewing) return "bg-blue-500 animate-pulse";
|
||||
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// The status dot should have animated blue styling
|
||||
const statusDot = screen.getByText(i18n.t('prReview.aiReviewInProgress')).parentElement?.querySelector('div');
|
||||
expect(statusDot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return status-appropriate dot color when not reviewing', () => {
|
||||
const { container: readyContainer } = render(
|
||||
<ReviewStatusTree
|
||||
status="ready_to_merge"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({ overallStatus: 'approve' })}
|
||||
previousReviewResult={null}
|
||||
postedCount={1}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show "Ready to Merge" label
|
||||
expect(screen.getByText(i18n.t('prReview.readyToMerge'))).toBeInTheDocument();
|
||||
|
||||
// Should NOT show cancel button when not reviewing
|
||||
expect(screen.queryByText(i18n.t('prReview.cancel'))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Label Logic', () => {
|
||||
it('should return "AI Review in Progress" when isReviewing', () => {
|
||||
// getStatusLabel function in ReviewStatusTree:
|
||||
// if (isReviewing) return t('prReview.aiReviewInProgress');
|
||||
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return appropriate label for other statuses when not reviewing', () => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
ready_to_merge: i18n.t('prReview.readyToMerge'),
|
||||
waiting_for_changes: i18n.t('prReview.waitingForChanges'),
|
||||
reviewed_pending_post: i18n.t('prReview.reviewComplete'),
|
||||
ready_for_followup: i18n.t('prReview.readyForFollowup'),
|
||||
needs_attention: i18n.t('prReview.needsAttention'),
|
||||
followup_issues_remain: i18n.t('prReview.blockingIssues'),
|
||||
};
|
||||
|
||||
for (const [status, expectedLabel] of Object.entries(statusLabels)) {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status={status as ReviewStatus}
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult()}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Completed Review States', () => {
|
||||
it('should show ready_to_merge status correctly', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="ready_to_merge"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({
|
||||
overallStatus: 'approve',
|
||||
findings: [],
|
||||
})}
|
||||
previousReviewResult={null}
|
||||
postedCount={1}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.readyToMerge'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show ready_for_followup status with run follow-up button', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="ready_for_followup"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({
|
||||
postedFindingIds: ['finding-1'],
|
||||
hasPostedFindings: true,
|
||||
})}
|
||||
previousReviewResult={null}
|
||||
postedCount={1}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={createMockNewCommitsCheck({
|
||||
hasNewCommits: true,
|
||||
newCommitCount: 3,
|
||||
hasCommitsAfterPosting: true,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// Component should render without errors - "Ready for Follow-up" appears in both header and tree
|
||||
expect(screen.getAllByText(/Ready for Follow-up/i).length).toBeGreaterThan(0);
|
||||
|
||||
// Should show run follow-up button
|
||||
const followUpButton = screen.getByText(/Run Follow-up/i);
|
||||
followUpButton.click();
|
||||
expect(mockOnRunFollowupReview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACS-200 Integration Scenarios', () => {
|
||||
describe('Scenario: Switching between PRs with different review states', () => {
|
||||
it('should correctly display reviewing state when switching back to reviewing PR', () => {
|
||||
// PR #1: Review in progress
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t('prReview.cancel'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario: Review completes while viewing another PR', () => {
|
||||
it('should show completed status when review finishes', () => {
|
||||
// During review
|
||||
const { rerender } = render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
|
||||
// Review completes
|
||||
rerender(
|
||||
<ReviewStatusTree
|
||||
status="reviewed_pending_post"
|
||||
isReviewing={false}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
severity: 'low',
|
||||
category: 'style',
|
||||
title: 'Style issue',
|
||||
description: 'Minor style issue',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
fixable: true,
|
||||
},
|
||||
],
|
||||
})}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.reviewComplete'))).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t('prReview.cancel'))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null reviewResult with isReviewing=true', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={null}
|
||||
previousReviewResult={null}
|
||||
postedCount={0}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should still show reviewing state
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle followup in progress with reviewResult present', () => {
|
||||
render(
|
||||
<ReviewStatusTree
|
||||
status="not_reviewed"
|
||||
isReviewing={true}
|
||||
startedAt={null}
|
||||
reviewResult={createMockReviewResult({ isFollowupReview: true })}
|
||||
previousReviewResult={createMockReviewResult({
|
||||
findings: [{ id: 'f1', severity: 'high', category: 'security', title: 'Issue', description: 'Fix', file: 'test.ts', line: 1, fixable: true }],
|
||||
postedFindingIds: ['f1'],
|
||||
})}
|
||||
postedCount={1}
|
||||
onRunReview={mockOnRunReview}
|
||||
onRunFollowupReview={mockOnRunFollowupReview}
|
||||
onCancelReview={mockOnCancelReview}
|
||||
newCommitsCheck={createMockNewCommitsCheck({ hasNewCommits: true, newCommitCount: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t('prReview.aiReviewInProgress'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ interface UseGitHubPRsResult {
|
||||
reviewResult: PRReviewResult | null;
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
isReviewing: boolean;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
startedAt: string | null;
|
||||
isConnected: boolean;
|
||||
repoFullName: string | null;
|
||||
activePRReviews: number[]; // PR numbers currently being reviewed
|
||||
@@ -52,6 +54,7 @@ interface UseGitHubPRsResult {
|
||||
assignPR: (prNumber: number, username: string) => Promise<boolean>;
|
||||
getReviewStateForPR: (prNumber: number) => {
|
||||
isReviewing: boolean;
|
||||
startedAt: string | null;
|
||||
progress: PRReviewProgress | null;
|
||||
result: PRReviewResult | null;
|
||||
previousResult: PRReviewResult | null;
|
||||
@@ -96,10 +99,12 @@ export function useGitHubPRs(
|
||||
return getPRReviewState(projectId, selectedPRNumber);
|
||||
}, [projectId, selectedPRNumber, prReviews, getPRReviewState]);
|
||||
|
||||
// Derive values from store state
|
||||
// Derive values from store state - all from the same source to ensure consistency
|
||||
const reviewResult = selectedPRReviewState?.result ?? null;
|
||||
const reviewProgress = selectedPRReviewState?.progress ?? null;
|
||||
const isReviewing = selectedPRReviewState?.isReviewing ?? false;
|
||||
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
|
||||
const startedAt = selectedPRReviewState?.startedAt ?? null;
|
||||
|
||||
// Get list of PR numbers currently being reviewed
|
||||
const activePRReviews = useMemo(() => {
|
||||
@@ -115,6 +120,7 @@ export function useGitHubPRs(
|
||||
if (!state) return null;
|
||||
return {
|
||||
isReviewing: state.isReviewing,
|
||||
startedAt: state.startedAt,
|
||||
progress: state.progress,
|
||||
result: state.result,
|
||||
previousResult: state.previousResult,
|
||||
@@ -501,6 +507,8 @@ export function useGitHubPRs(
|
||||
reviewResult,
|
||||
reviewProgress,
|
||||
isReviewing,
|
||||
previousReviewResult,
|
||||
startedAt,
|
||||
isConnected,
|
||||
repoFullName,
|
||||
activePRReviews,
|
||||
|
||||
@@ -12,6 +12,8 @@ interface PRReviewState {
|
||||
prNumber: number;
|
||||
projectId: string;
|
||||
isReviewing: boolean;
|
||||
/** Timestamp when the review was started (ISO 8601 string) */
|
||||
startedAt: string | null;
|
||||
progress: PRReviewProgress | null;
|
||||
result: PRReviewResult | null;
|
||||
/** Previous review result - preserved during follow-up review for continuity */
|
||||
@@ -55,6 +57,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: null,
|
||||
@@ -84,6 +87,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: existing?.result ?? null, // Preserve for follow-up continuity
|
||||
@@ -104,6 +108,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber: progress.prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
startedAt: existing?.startedAt ?? null,
|
||||
progress,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
@@ -124,6 +129,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber: result.prNumber,
|
||||
projectId,
|
||||
isReviewing: false,
|
||||
startedAt: null,
|
||||
progress: null,
|
||||
result,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
@@ -146,6 +152,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: false,
|
||||
startedAt: null,
|
||||
progress: null,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
@@ -168,6 +175,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: false,
|
||||
startedAt: null,
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: null,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"paths": {
|
||||
"@/*": ["src/renderer/*"],
|
||||
"@shared/*": ["src/shared/*"],
|
||||
"@preload/*": ["src/preload/*"],
|
||||
"@features/*": ["src/renderer/features/*"],
|
||||
"@components/*": ["src/renderer/shared/components/*"],
|
||||
"@hooks/*": ["src/renderer/shared/hooks/*"],
|
||||
|
||||
Reference in New Issue
Block a user