-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
auto-claude: 221-refactor-github-pr-review-with-xstate #1820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AndyMik90
wants to merge
15
commits into
develop
Choose a base branch
from
auto-claude/221-refactor-github-pr-review-with-xstate
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,443
−352
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
45eebe9
auto-claude: subtask-1-1 - Create XState v5 PR review state machine a…
AndyMik90 f3e9105
auto-claude: subtask-1-2 - Write comprehensive unit tests for the PR …
AndyMik90 b4264ae
auto-claude: subtask-2-1 - Add IPC channel constant and preload API l…
AndyMik90 bfdbdb5
auto-claude: subtask-2-2 - Create PRReviewStateManager class in Elect…
AndyMik90 8c0b025
auto-claude: subtask-2-3 - Write unit tests for PRReviewStateManager.
AndyMik90 df819b3
auto-claude: subtask-3-1 - Refactor pr-handlers.ts to use PRReviewSta…
AndyMik90 a0c8e89
auto-claude: subtask-4-1 - Rewrite pr-review-store.ts as thin XState …
AndyMik90 181fe50
auto-claude: subtask-4-2 - Update useGitHubPRs hook for new XState-dr…
AndyMik90 16f3194
auto-claude: subtask-5-1 - Fix type errors and verify full test suite
AndyMik90 b3d6bbd
fix: resolve IPC serialization, auth change wiring, and error→followu…
AndyMik90 1b65a7b
fix: resolve XState gaps in external review polling, cancel/clear han…
AndyMik90 947fc3c
fix: address follow-up review findings — unhandled rejection, auth pa…
AndyMik90 58fec15
fix: prevent OOM, orphaned agents, and unbounded growth during overni…
AndyMik90 4a151ae
fix: address low-severity review findings — stale progress, dead code…
AndyMik90 191736d
fix: address PR review findings for XState PR review refactor
AndyMik90 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
333 changes: 333 additions & 0 deletions
333
apps/frontend/src/main/__tests__/pr-review-state-manager.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,333 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { PRReviewStateManager } from '../pr-review-state-manager'; | ||
| import type { PRReviewResult, PRReviewProgress } from '../../preload/api/modules/github-api'; | ||
|
|
||
| // Mock dependencies | ||
| const mockSafeSendToRenderer = vi.fn(); | ||
| vi.mock('../ipc-handlers/utils', () => ({ | ||
| safeSendToRenderer: (...args: unknown[]) => mockSafeSendToRenderer(...args) | ||
| })); | ||
|
|
||
| function createMockGetMainWindow() { | ||
| return vi.fn(() => ({ id: 1 }) as unknown as Electron.BrowserWindow); | ||
| } | ||
|
|
||
| function createMockProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress { | ||
| return { | ||
| phase: 'analyzing', | ||
| progress: 50, | ||
| message: 'Analyzing files...', | ||
| ...overrides | ||
| } as PRReviewProgress; | ||
| } | ||
|
|
||
| function createMockResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult { | ||
| return { | ||
| overallStatus: 'approved', | ||
| summary: 'Looks good', | ||
| ...overrides | ||
| } as PRReviewResult; | ||
| } | ||
|
Comment on lines
+15
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mock PRReviewResult/PRReviewProgress should match the real shape. 🧪 Suggested fix function createMockProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress {
return {
+ prNumber: overrides.prNumber ?? 42,
phase: 'analyzing',
progress: 50,
message: 'Analyzing files...',
...overrides
} as PRReviewProgress;
}
function createMockResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
return {
- overallStatus: 'approved',
+ prNumber: overrides.prNumber ?? 42,
+ repo: 'test/repo',
+ success: true,
+ findings: [],
summary: 'Looks good',
+ overallStatus: 'approve',
+ reviewedAt: new Date().toISOString(),
...overrides
} as PRReviewResult;
}🤖 Prompt for AI Agents |
||
|
|
||
| describe('PRReviewStateManager', () => { | ||
| let manager: PRReviewStateManager; | ||
| const projectId = 'project-1'; | ||
| const prNumber = 42; | ||
|
|
||
| beforeEach(() => { | ||
| manager = new PRReviewStateManager(createMockGetMainWindow()); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| manager.clearAll(); | ||
| }); | ||
|
|
||
| describe('actor lifecycle', () => { | ||
| it('should create actor on first handleStartReview call', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(snapshot).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('should reuse existing actor for same PR key', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const snapshot1 = manager.getState(projectId, prNumber); | ||
| // Calling again should not create a new actor | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const snapshot2 = manager.getState(projectId, prNumber); | ||
| expect(snapshot1).not.toBeNull(); | ||
| expect(snapshot2).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('should create separate actors for different PRs', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
| const snapshot1 = manager.getState(projectId, 1); | ||
| const snapshot2 = manager.getState(projectId, 2); | ||
| expect(snapshot1).not.toBeNull(); | ||
| expect(snapshot2).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('should start actor before events are sent', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| // If actor wasn't started, getSnapshot would fail or return unexpected state | ||
| expect(snapshot).not.toBeNull(); | ||
| expect(String(snapshot!.value)).toBe('reviewing'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('event routing', () => { | ||
| it('should transition to reviewing on handleStartReview', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('reviewing'); | ||
| }); | ||
|
|
||
| it('should send START_FOLLOWUP_REVIEW with previousResult', () => { | ||
| const previousResult = createMockResult(); | ||
| manager.handleStartFollowupReview(projectId, prNumber, previousResult); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('reviewing'); | ||
| expect(snapshot!.context.isFollowup).toBe(true); | ||
| expect(snapshot!.context.previousResult).toBe(previousResult); | ||
| }); | ||
|
|
||
| it('should send START_REVIEW when handleStartFollowupReview has no previousResult', () => { | ||
| manager.handleStartFollowupReview(projectId, prNumber); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('reviewing'); | ||
| expect(snapshot!.context.isFollowup).toBe(false); | ||
| }); | ||
|
|
||
| it('should update context on handleProgress', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const progress = createMockProgress(); | ||
| manager.handleProgress(projectId, prNumber, progress); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(snapshot!.context.progress).toEqual(progress); | ||
| }); | ||
|
|
||
| it('should ignore handleProgress for unknown PR', () => { | ||
| // Should not throw | ||
| manager.handleProgress(projectId, 999, createMockProgress()); | ||
| expect(manager.getState(projectId, 999)).toBeNull(); | ||
| }); | ||
|
|
||
| it('should transition to completed on handleComplete', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const result = createMockResult(); | ||
| manager.handleComplete(projectId, prNumber, result); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('completed'); | ||
| expect(snapshot!.context.result).toEqual(result); | ||
| }); | ||
|
|
||
| it('should create actor for handleComplete on unknown PR (late-arriving result)', () => { | ||
| const result = createMockResult(); | ||
| // No handleStartReview called — handleComplete should create the actor | ||
| manager.handleComplete(projectId, prNumber, result); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(snapshot).not.toBeNull(); | ||
| expect(snapshot!.context.result).toEqual(result); | ||
| }); | ||
|
|
||
| it('should send DETECT_EXTERNAL_REVIEW when overallStatus is in_progress', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const result = createMockResult({ overallStatus: 'in_progress' }); | ||
| manager.handleComplete(projectId, prNumber, result); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('externalReview'); | ||
| }); | ||
|
|
||
| it('should transition to error on handleError', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| manager.handleError(projectId, prNumber, 'Something went wrong'); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('error'); | ||
| expect(snapshot!.context.error).toBe('Something went wrong'); | ||
| }); | ||
|
|
||
| it('should transition to error on handleCancel', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| manager.handleCancel(projectId, prNumber); | ||
| const snapshot = manager.getState(projectId, prNumber); | ||
| expect(String(snapshot!.value)).toBe('error'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('state emission', () => { | ||
| it('should emit state changes to renderer via safeSendToRenderer', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| expect(mockSafeSendToRenderer).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should use GITHUB_PR_REVIEW_STATE_CHANGE IPC channel', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| expect(mockSafeSendToRenderer).toHaveBeenCalledWith( | ||
| expect.any(Function), | ||
| 'github:pr:reviewStateChange', | ||
| expect.any(String), | ||
| expect.objectContaining({ state: expect.any(String) }) | ||
| ); | ||
| }); | ||
|
|
||
| it('should emit PRReviewStatePayload with correct shape', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| // Find the call that emits 'reviewing' state | ||
| const reviewingCall = mockSafeSendToRenderer.mock.calls.find( | ||
| (call: unknown[]) => { | ||
| const payload = call[3] as Record<string, unknown> | undefined; | ||
| return payload && typeof payload === 'object' && payload.state === 'reviewing'; | ||
| } | ||
| ); | ||
| expect(reviewingCall).toBeDefined(); | ||
| expect(reviewingCall![2]).toBe(`${projectId}:${prNumber}`); | ||
| const payload = reviewingCall![3] as Record<string, unknown>; | ||
| expect(payload).toEqual(expect.objectContaining({ | ||
| state: 'reviewing', | ||
| prNumber, | ||
| projectId, | ||
| isReviewing: true, | ||
| startedAt: expect.any(String), | ||
| progress: null, | ||
| result: null, | ||
| previousResult: null, | ||
| error: null, | ||
| isExternalReview: false, | ||
| isFollowup: false, | ||
| })); | ||
| }); | ||
|
|
||
| it('should use projectId:prNumber as key format', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const calls = mockSafeSendToRenderer.mock.calls; | ||
| const prCall = calls.find((call: unknown[]) => call[2] === `${projectId}:${prNumber}`); | ||
| expect(prCall).toBeDefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('deduplication', () => { | ||
| it('should NOT emit duplicate IPC for same state + same context', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const callCountAfterStart = mockSafeSendToRenderer.mock.calls.length; | ||
|
|
||
| // Sending START_REVIEW again won't transition (guard prevents it), so no new emission | ||
| manager.handleStartReview(projectId, prNumber); | ||
| expect(mockSafeSendToRenderer.mock.calls.length).toBe(callCountAfterStart); | ||
| }); | ||
|
|
||
| it('should emit for same state but different context (progress update)', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const callCountAfterStart = mockSafeSendToRenderer.mock.calls.length; | ||
|
|
||
| manager.handleProgress(projectId, prNumber, createMockProgress({ progress: 25, message: 'Step 1' })); | ||
| expect(mockSafeSendToRenderer.mock.calls.length).toBeGreaterThan(callCountAfterStart); | ||
|
|
||
| const callCountAfterProgress1 = mockSafeSendToRenderer.mock.calls.length; | ||
| manager.handleProgress(projectId, prNumber, createMockProgress({ progress: 75, message: 'Step 2' })); | ||
| expect(mockSafeSendToRenderer.mock.calls.length).toBeGreaterThan(callCountAfterProgress1); | ||
| }); | ||
|
|
||
| it('should always emit for different state transitions', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| const callCountAfterStart = mockSafeSendToRenderer.mock.calls.length; | ||
|
|
||
| manager.handleComplete(projectId, prNumber, createMockResult()); | ||
| expect(mockSafeSendToRenderer.mock.calls.length).toBeGreaterThan(callCountAfterStart); | ||
| }); | ||
| }); | ||
|
|
||
| describe('cleanup', () => { | ||
| it('should stop actor and remove from map on handleClearReview', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| expect(manager.getState(projectId, prNumber)).not.toBeNull(); | ||
|
|
||
| manager.handleClearReview(projectId, prNumber); | ||
| expect(manager.getState(projectId, prNumber)).toBeNull(); | ||
| }); | ||
|
|
||
| it('should emit exactly one cleared state IPC on handleClearReview (no double emission)', () => { | ||
| manager.handleStartReview(projectId, prNumber); | ||
| mockSafeSendToRenderer.mockClear(); | ||
|
|
||
| manager.handleClearReview(projectId, prNumber); | ||
|
|
||
| // Should emit exactly 1 cleared state, not 2 (no double emission from | ||
| // sending CLEAR_REVIEW to actor subscription + manual emitClearedState) | ||
| expect(mockSafeSendToRenderer).toHaveBeenCalledTimes(1); | ||
| const payload = mockSafeSendToRenderer.mock.calls[0][3] as Record<string, unknown>; | ||
| expect(payload).toEqual(expect.objectContaining({ state: 'idle' })); | ||
| }); | ||
|
|
||
| it('should stop ALL actors and clear maps on handleAuthChange', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
|
|
||
| manager.handleAuthChange(); | ||
|
|
||
| expect(manager.getState(projectId, 1)).toBeNull(); | ||
| expect(manager.getState(projectId, 2)).toBeNull(); | ||
| }); | ||
|
|
||
| it('should emit cleared state to renderer on handleAuthChange', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
| mockSafeSendToRenderer.mockClear(); | ||
|
|
||
| manager.handleAuthChange(); | ||
|
|
||
| // Should emit idle/null state for each PR | ||
| expect(mockSafeSendToRenderer).toHaveBeenCalledTimes(2); | ||
| for (const call of mockSafeSendToRenderer.mock.calls) { | ||
| const payload = call[3] as Record<string, unknown>; | ||
| expect(payload).toEqual(expect.objectContaining({ state: 'idle' })); | ||
| } | ||
| }); | ||
|
|
||
| it('should stop all actors on clearAll', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
|
|
||
| manager.clearAll(); | ||
|
|
||
| expect(manager.getState(projectId, 1)).toBeNull(); | ||
| expect(manager.getState(projectId, 2)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('concurrent PRs', () => { | ||
| it('should support multiple PRs with independent actors', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
|
|
||
| manager.handleComplete(projectId, 1, createMockResult()); | ||
|
|
||
| expect(String(manager.getState(projectId, 1)!.value)).toBe('completed'); | ||
| expect(String(manager.getState(projectId, 2)!.value)).toBe('reviewing'); | ||
| }); | ||
|
|
||
| it('should route events to correct actor by key', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
|
|
||
| manager.handleError(projectId, 2, 'Error on PR 2'); | ||
|
|
||
| expect(String(manager.getState(projectId, 1)!.value)).toBe('reviewing'); | ||
| expect(String(manager.getState(projectId, 2)!.value)).toBe('error'); | ||
| expect(manager.getState(projectId, 2)!.context.error).toBe('Error on PR 2'); | ||
| }); | ||
|
|
||
| it('should not affect other PRs when clearing one', () => { | ||
| manager.handleStartReview(projectId, 1); | ||
| manager.handleStartReview(projectId, 2); | ||
|
|
||
| manager.handleClearReview(projectId, 1); | ||
|
|
||
| expect(manager.getState(projectId, 1)).toBeNull(); | ||
| expect(manager.getState(projectId, 2)).not.toBeNull(); | ||
| expect(String(manager.getState(projectId, 2)!.value)).toBe('reviewing'); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Switch test imports to path aliases.
Use the configured aliases for main/preload imports.
🔧 Suggested change
📝 Committable suggestion
🤖 Prompt for AI Agents