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:
StillKnotKnown
2026-01-11 01:23:56 +02:00
committed by GitHub
parent 6dc538c86f
commit d9ed8179d6
8 changed files with 1474 additions and 8 deletions
@@ -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
@@ -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');
});
});
});
@@ -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,
+1
View File
@@ -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/*"],