Merge pull request #1820 from AndyMik90/auto-claude/221-refactor-github-pr-review-with-xstate

auto-claude: 221-refactor-github-pr-review-with-xstate
This commit is contained in:
Andy
2026-02-20 11:54:18 +01:00
committed by GitHub
16 changed files with 1443 additions and 352 deletions
@@ -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;
}
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');
});
});
});
@@ -51,6 +51,10 @@ function sendAuthChangedToRenderer(oldUsername: string | null, newUsername: stri
for (const win of windows) {
win.webContents.send(IPC_CHANNELS.GITHUB_AUTH_CHANGED, payload);
}
// Uses EventEmitter.emit (not IPC send) so main-process listeners can react.
// The listener (PRReviewStateManager) intentionally ignores all args — it only
// needs the event signal, not the payload.
ipcMain.emit(IPC_CHANNELS.GITHUB_AUTH_CHANGED, payload);
}
/**
@@ -26,7 +26,7 @@ import { getMemoryService, getDefaultDbPath } from "../../memory-service";
import type { Project, AppSettings } from "../../../shared/types";
import { createContextLogger } from "./utils/logger";
import { withProjectOrNull } from "./utils/project-middleware";
import { createIPCCommunicators } from "./utils/ipc-communicator";
import { PRReviewStateManager } from "../../pr-review-state-manager";
import { getRunnerEnv } from "./utils/runner-env";
import {
runPythonSubprocess,
@@ -1384,7 +1384,7 @@ function sendReviewStateUpdate(
project: Project,
prNumber: number,
projectId: string,
getMainWindow: () => BrowserWindow | null,
prReviewStateManager: PRReviewStateManager,
context: string
): void {
try {
@@ -1393,18 +1393,8 @@ function sendReviewStateUpdate(
debugLog("Could not retrieve updated review result for UI notification", { prNumber, context });
return;
}
const mainWindow = getMainWindow();
if (!mainWindow) return;
const { sendComplete } = createIPCCommunicators<PRReviewProgress, PRReviewResult>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
projectId
);
sendComplete(updatedResult);
// Route through state manager so the XState actor emits the state change
prReviewStateManager.handleComplete(projectId, prNumber, updatedResult);
debugLog(`Sent PR review state update ${context}`, { prNumber });
} catch (uiError) {
debugLog("Failed to send UI update (non-critical)", {
@@ -1445,7 +1435,8 @@ function getGitHubPRSettings(): { model: string; thinkingLevel: string } {
async function runPRReview(
project: Project,
prNumber: number,
mainWindow: BrowserWindow
mainWindow: BrowserWindow,
prReviewStateManager: PRReviewStateManager
): Promise<PRReviewResult> {
// Comprehensive validation of GitHub module
const validation = await validateGitHubModule(project);
@@ -1456,15 +1447,9 @@ async function runPRReview(
const backendPath = validation.backendPath!;
const { sendProgress } = createIPCCommunicators<PRReviewProgress, PRReviewResult>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
project.id
);
const sendProgress = (progress: PRReviewProgress): void => {
prReviewStateManager.handleProgress(project.id, prNumber, progress);
};
const { model, thinkingLevel } = getGitHubPRSettings();
const args = buildRunnerArgs(
@@ -1695,6 +1680,32 @@ async function fetchPRsFromGraphQL(
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
debugLog("Registering PR handlers");
// Create the XState-based PR review state manager
const prReviewStateManager = new PRReviewStateManager(getMainWindow);
// Clear all PR review actors when GitHub auth changes (account swap)
ipcMain.on(IPC_CHANNELS.GITHUB_AUTH_CHANGED, () => {
// Cancel all running review subprocesses and CI wait controllers
for (const [reviewKey, entry] of runningReviews) {
if (entry === CI_WAIT_PLACEHOLDER) {
const abortController = ciWaitAbortControllers.get(reviewKey);
if (abortController) {
abortController.abort();
ciWaitAbortControllers.delete(reviewKey);
}
} else {
try {
entry.kill("SIGTERM");
} catch {
// Process may have already exited
}
}
}
runningReviews.clear();
ciWaitAbortControllers.clear();
prReviewStateManager.handleAuthChange();
});
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST,
@@ -1902,31 +1913,27 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
try {
await withProjectOrNull(projectId, async (project) => {
const { sendProgress, sendComplete } = createIPCCommunicators<
PRReviewProgress,
PRReviewResult
>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
projectId
);
const sendProgress = (progress: PRReviewProgress): void => {
prReviewStateManager.handleProgress(projectId, prNumber, progress);
};
// Check if already running — notify renderer so it can display ongoing logs
if (runningReviews.has(reviewKey)) {
debugLog("Review already running, notifying renderer", { reviewKey });
const currentSnapshot = prReviewStateManager.getState(projectId, prNumber);
const currentProgress = currentSnapshot?.context?.progress?.progress ?? 50;
sendProgress({
phase: "analyzing",
prNumber,
progress: 50,
progress: currentProgress,
message: "Review is already in progress. Reconnecting to ongoing review...",
});
return;
}
// Notify state manager that review is starting (after duplicate check)
prReviewStateManager.handleStartReview(projectId, prNumber);
// Register as running BEFORE CI wait to prevent race conditions
// Use CI_WAIT_PLACEHOLDER sentinel until real process is spawned
runningReviews.set(reviewKey, CI_WAIT_PLACEHOLDER);
@@ -1991,31 +1998,18 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
message: "Fetching PR data...",
});
const result = await runPRReview(project, prNumber, mainWindow);
if (result.overallStatus === "in_progress") {
// Review is already running externally (detected by BotDetector).
// Send the result as-is so the renderer can activate external review polling.
debugLog("PR review already in progress externally", { prNumber });
sendProgress({
phase: "complete",
prNumber,
progress: 100,
message: "Review already in progress",
});
sendComplete(result);
return;
}
const result = await runPRReview(project, prNumber, mainWindow, prReviewStateManager);
debugLog("PR review completed", { prNumber, findingsCount: result.findings.length });
sendProgress({
phase: "complete",
prNumber,
progress: 100,
message: "Review complete!",
message: result.overallStatus === "in_progress" ? "Review already in progress" : "Review complete!",
});
sendComplete(result);
// Route through manager — handles external review detection internally
prReviewStateManager.handleComplete(projectId, prNumber, result);
} finally {
// Clean up in case we exit before runPRReview was called (e.g., cancelled during CI wait)
// runPRReview also has its own cleanup, but delete is idempotent
@@ -2032,16 +2026,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
prNumber,
error: error instanceof Error ? error.message : error,
});
const { sendError } = createIPCCommunicators<PRReviewProgress, PRReviewResult>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
projectId
);
sendError({ prNumber, error: error instanceof Error ? error.message : "Failed to run PR review" });
prReviewStateManager.handleError(projectId, prNumber, error instanceof Error ? error.message : "Failed to run PR review");
}
});
@@ -2246,7 +2231,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
}
// Send state update event to refresh UI immediately (non-blocking)
sendReviewStateUpdate(project, prNumber, projectId, getMainWindow, "after posting");
sendReviewStateUpdate(project, prNumber, projectId, prReviewStateManager, "after posting");
return true;
} catch (error) {
@@ -2285,7 +2270,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
debugLog("Marked review as posted", { prNumber });
// Send state update event to refresh UI immediately (non-blocking)
sendReviewStateUpdate(project, prNumber, projectId, getMainWindow, "after marking posted");
sendReviewStateUpdate(project, prNumber, projectId, prReviewStateManager, "after marking posted");
return true;
} catch (error) {
@@ -2403,7 +2388,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
}
// Send state update event to refresh UI immediately (non-blocking)
sendReviewStateUpdate(project, prNumber, projectId, getMainWindow, "after deletion");
sendReviewStateUpdate(project, prNumber, projectId, prReviewStateManager, "after deletion");
return true;
} catch (error) {
@@ -2515,6 +2500,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
ciWaitAbortControllers.delete(reviewKey);
}
runningReviews.delete(reviewKey);
// Notify state manager of cancellation
prReviewStateManager.handleCancel(projectId, prNumber);
debugLog("CI wait cancelled", { reviewKey });
return true;
}
@@ -2535,6 +2522,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
// Clean up the registry
runningReviews.delete(reviewKey);
// Notify state manager of cancellation
prReviewStateManager.handleCancel(projectId, prNumber);
debugLog("Review process cancelled", { reviewKey });
return true;
} catch (error) {
@@ -2547,6 +2536,21 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
}
);
// Notify main process about external review completion or timeout
// Called by renderer when its polling detects an external review has finished on disk
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_NOTIFY_EXTERNAL_REVIEW_COMPLETE,
async (_, projectId: string, prNumber: number, result: PRReviewResult | null): Promise<void> => {
debugLog("notifyExternalReviewComplete handler called", { projectId, prNumber, hasResult: !!result });
if (result) {
prReviewStateManager.handleComplete(projectId, prNumber, result);
} else {
// Timeout — no result found within polling window
prReviewStateManager.handleError(projectId, prNumber, "External review timed out after 30 minutes");
}
}
);
// Check for new commits since last review
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_CHECK_NEW_COMMITS,
@@ -2932,34 +2936,40 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
try {
await withProjectOrNull(projectId, async (project) => {
const { sendProgress, sendError, sendComplete } = createIPCCommunicators<
PRReviewProgress,
PRReviewResult
>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
projectId
);
const sendProgress = (progress: PRReviewProgress): void => {
prReviewStateManager.handleProgress(projectId, prNumber, progress);
};
const reviewKey = getReviewKey(projectId, prNumber);
// Check if already running — notify renderer so it can display ongoing logs
if (runningReviews.has(reviewKey)) {
debugLog("Follow-up review already running, notifying renderer", { reviewKey });
const currentSnapshot = prReviewStateManager.getState(projectId, prNumber);
const currentProgress = currentSnapshot?.context?.progress?.progress ?? 50;
sendProgress({
phase: "analyzing",
prNumber,
progress: currentProgress,
message: "Follow-up review is already in progress. Reconnecting to ongoing review...",
});
return;
}
// Get previous result for followup context
const previousResult = getReviewResult(project, prNumber) ?? undefined;
// Notify state manager that followup review is starting (after duplicate check)
prReviewStateManager.handleStartFollowupReview(projectId, prNumber, previousResult);
// Comprehensive validation of GitHub module
const validation = await validateGitHubModule(project);
if (!validation.valid) {
sendError({ prNumber, error: validation.error || "GitHub module validation failed" });
prReviewStateManager.handleError(projectId, prNumber, validation.error || "GitHub module validation failed");
return;
}
const backendPath = validation.backendPath!;
const reviewKey = getReviewKey(projectId, prNumber);
// Check if already running
if (runningReviews.has(reviewKey)) {
debugLog("Follow-up review already running", { reviewKey });
return;
}
// Register as running BEFORE CI wait to prevent race conditions
// Use CI_WAIT_PLACEHOLDER sentinel until real process is spawned
@@ -3129,7 +3139,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
message: "Follow-up review complete!",
});
sendComplete(result.data!);
// Route through state manager
prReviewStateManager.handleComplete(projectId, prNumber, result.data!);
} finally {
// Always clean up registry, whether we exit normally or via error
runningReviews.delete(reviewKey);
@@ -3142,19 +3153,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
prNumber,
error: error instanceof Error ? error.message : error,
});
const { sendError } = createIPCCommunicators<PRReviewProgress, PRReviewResult>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_PR_REVIEW_PROGRESS,
error: IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR,
complete: IPC_CHANNELS.GITHUB_PR_REVIEW_COMPLETE,
},
projectId
);
sendError({
prNumber,
error: error instanceof Error ? error.message : "Failed to run follow-up review",
});
prReviewStateManager.handleError(projectId, prNumber, error instanceof Error ? error.message : "Failed to run follow-up review");
}
}
);
@@ -0,0 +1,217 @@
import { createActor } from 'xstate';
import type { ActorRefFrom } from 'xstate';
import type { BrowserWindow } from 'electron';
import { prReviewMachine, type PRReviewEvent, type PRReviewContext } from '../shared/state-machines';
import type { PRReviewProgress, PRReviewResult, PRReviewStatePayload } from '../preload/api/modules/github-api';
import { IPC_CHANNELS } from '../shared/constants';
import { safeSendToRenderer } from './ipc-handlers/utils';
type PRReviewActor = ActorRefFrom<typeof prReviewMachine>;
/**
* Build a deduplication key from snapshot state + relevant context fields.
* PR reviews need to emit even when state stays the same but context changes
* (e.g., progress updates within 'reviewing' state).
*/
function buildContextKey(snapshot: { context: PRReviewContext }): string {
const ctx = snapshot.context;
const progressKey = ctx.progress
? `${ctx.progress.phase}:${ctx.progress.progress}:${ctx.progress.message}`
: 'none';
const resultKey = ctx.result ? ctx.result.overallStatus : 'none';
const errorKey = ctx.error ?? 'none';
return `${progressKey}|${resultKey}|${errorKey}`;
}
export class PRReviewStateManager {
private actors = new Map<string, PRReviewActor>();
private lastStateByPR = new Map<string, string>();
private getMainWindow: () => BrowserWindow | null;
constructor(getMainWindow: () => BrowserWindow | null) {
this.getMainWindow = getMainWindow;
}
handleStartReview(projectId: string, prNumber: number): void {
const actor = this.getOrCreateActor(projectId, prNumber);
actor.send({ type: 'START_REVIEW', prNumber, projectId } satisfies PRReviewEvent);
}
handleStartFollowupReview(projectId: string, prNumber: number, previousResult?: PRReviewResult): void {
const actor = this.getOrCreateActor(projectId, prNumber);
if (previousResult) {
actor.send({ type: 'START_FOLLOWUP_REVIEW', prNumber, projectId, previousResult } satisfies PRReviewEvent);
} else {
actor.send({ type: 'START_REVIEW', prNumber, projectId } satisfies PRReviewEvent);
}
}
handleProgress(projectId: string, prNumber: number, progress: PRReviewProgress): void {
const actor = this.getActor(projectId, prNumber);
if (!actor) return;
actor.send({ type: 'SET_PROGRESS', progress } satisfies PRReviewEvent);
}
handleComplete(projectId: string, prNumber: number, result: PRReviewResult): void {
// Use getOrCreateActor so late-arriving results (e.g. after auth change or
// app restart) still get processed instead of silently dropped.
const actor = this.getOrCreateActor(projectId, prNumber);
// If the actor is in idle state (freshly created for a late-arriving result),
// transition to reviewing first so REVIEW_COMPLETE is accepted.
const snapshot = actor.getSnapshot();
if (String(snapshot.value) === 'idle') {
actor.send({ type: 'START_REVIEW', prNumber, projectId } satisfies PRReviewEvent);
}
// Detect external review (result arrives with 'in_progress' status from outside)
if (result.overallStatus === 'in_progress') {
actor.send({ type: 'DETECT_EXTERNAL_REVIEW' } satisfies PRReviewEvent);
} else {
actor.send({ type: 'REVIEW_COMPLETE', result } satisfies PRReviewEvent);
}
}
handleError(projectId: string, prNumber: number, error: string): void {
const actor = this.getActor(projectId, prNumber);
if (!actor) return;
actor.send({ type: 'REVIEW_ERROR', error } satisfies PRReviewEvent);
}
handleCancel(projectId: string, prNumber: number): void {
const actor = this.getActor(projectId, prNumber);
if (!actor) return;
actor.send({ type: 'CANCEL_REVIEW' } satisfies PRReviewEvent);
}
handleClearReview(projectId: string, prNumber: number): void {
const key = this.getKey(projectId, prNumber);
const actor = this.actors.get(key);
if (actor) {
// Capture snapshot before stopping so the emitted payload has real context.
// Don't send CLEAR_REVIEW to the actor — that would trigger the subscription
// and cause a duplicate IPC emission alongside emitClearedState below.
const snapshot = actor.getSnapshot();
actor.stop();
this.actors.delete(key);
this.emitClearedState(key, snapshot?.context ?? null);
}
this.lastStateByPR.delete(key);
}
handleAuthChange(): void {
for (const [key, actor] of this.actors) {
// Capture the last known snapshot before stopping so the emitted payload
// contains the real projectId/prNumber instead of zeros.
const snapshot = actor.getSnapshot();
actor.stop();
// Emit cleared (idle) state to renderer for each PR
this.emitClearedState(key, snapshot?.context ?? null);
}
this.actors.clear();
this.lastStateByPR.clear();
}
getState(projectId: string, prNumber: number): ReturnType<PRReviewActor['getSnapshot']> | null {
const actor = this.getActor(projectId, prNumber);
if (!actor) return null;
return actor.getSnapshot();
}
clearAll(): void {
for (const [, actor] of this.actors) {
actor.stop();
}
this.actors.clear();
this.lastStateByPR.clear();
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private getOrCreateActor(projectId: string, prNumber: number): PRReviewActor {
const key = this.getKey(projectId, prNumber);
const existing = this.actors.get(key);
if (existing) return existing;
const actor = createActor(prReviewMachine);
actor.subscribe((snapshot) => {
const stateValue = String(snapshot.value);
const contextKey = buildContextKey(snapshot);
const currentKey = `${stateValue}:${contextKey}`;
if (this.lastStateByPR.get(key) === currentKey) return;
this.lastStateByPR.set(key, currentKey);
this.emitStateToRenderer(key, snapshot);
});
actor.start();
this.actors.set(key, actor);
return actor;
}
private getActor(projectId: string, prNumber: number): PRReviewActor | null {
return this.actors.get(this.getKey(projectId, prNumber)) ?? null;
}
private getKey(projectId: string, prNumber: number): string {
return `${projectId}:${prNumber}`;
}
private emitStateToRenderer(
key: string,
snapshot: ReturnType<PRReviewActor['getSnapshot']> | null
): void {
const stateValue = snapshot ? String(snapshot.value) : 'idle';
const ctx = snapshot?.context ?? null;
const payload: PRReviewStatePayload = {
state: stateValue,
prNumber: ctx?.prNumber ?? 0,
projectId: ctx?.projectId ?? '',
isReviewing: stateValue === 'reviewing' || stateValue === 'externalReview',
startedAt: ctx?.startedAt ?? null,
progress: ctx?.progress ?? null,
result: ctx?.result ?? null,
previousResult: ctx?.previousResult ?? null,
error: ctx?.error ?? null,
isExternalReview: ctx?.isExternalReview ?? false,
isFollowup: ctx?.isFollowup ?? false,
};
safeSendToRenderer(
this.getMainWindow,
IPC_CHANNELS.GITHUB_PR_REVIEW_STATE_CHANGE,
key,
payload
);
}
/**
* Emit a cleared (idle) state using context from the last snapshot
* so the payload contains the real projectId/prNumber.
*/
private emitClearedState(key: string, ctx: PRReviewContext | null): void {
const payload: PRReviewStatePayload = {
state: 'idle',
prNumber: ctx?.prNumber ?? 0,
projectId: ctx?.projectId ?? '',
isReviewing: false,
startedAt: null,
progress: null,
result: null,
previousResult: null,
error: null,
isExternalReview: false,
isFollowup: false,
};
safeSendToRenderer(
this.getMainWindow,
IPC_CHANNELS.GITHUB_PR_REVIEW_STATE_CHANGE,
key,
payload
);
}
}
@@ -285,6 +285,9 @@ export interface GitHubAPI {
getPRReview: (projectId: string, prNumber: number) => Promise<PRReviewResult | null>;
getPRReviewsBatch: (projectId: string, prNumbers: number[]) => Promise<Record<number, PRReviewResult | null>>;
// External review notification (renderer tells main process about external review completion/timeout)
notifyExternalReviewComplete: (projectId: string, prNumber: number, result: PRReviewResult | null) => Promise<void>;
// Follow-up review operations
checkNewCommits: (projectId: string, prNumber: number) => Promise<NewCommitsCheck>;
checkMergeReadiness: (projectId: string, prNumber: number) => Promise<MergeReadiness>;
@@ -308,6 +311,9 @@ export interface GitHubAPI {
onPRReviewError: (
callback: (projectId: string, error: { prNumber: number; error: string }) => void
) => IpcListenerCleanup;
onPRReviewStateChange: (
callback: (key: string, state: PRReviewStatePayload) => void
) => IpcListenerCleanup;
onPRLogsUpdated: (
callback: (projectId: string, data: { prNumber: number; entryCount: number }) => void
) => IpcListenerCleanup;
@@ -456,6 +462,23 @@ export interface PRReviewProgress {
message: string;
}
/**
* PR review state payload (emitted on state machine transitions)
*/
export interface PRReviewStatePayload {
state: string;
prNumber: number;
projectId: string;
isReviewing: boolean;
startedAt: string | null;
progress: PRReviewProgress | null;
result: PRReviewResult | null;
previousResult: PRReviewResult | null;
error: string | null;
isExternalReview: boolean;
isFollowup: boolean;
}
/**
* PR review log entry type
*/
@@ -740,6 +763,10 @@ export const createGitHubAPI = (): GitHubAPI => ({
getPRReviewsBatch: (projectId: string, prNumbers: number[]): Promise<Record<number, PRReviewResult | null>> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH, projectId, prNumbers),
// External review notification
notifyExternalReviewComplete: (projectId: string, prNumber: number, result: PRReviewResult | null): Promise<void> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_NOTIFY_EXTERNAL_REVIEW_COMPLETE, projectId, prNumber, result),
// Follow-up review operations
checkNewCommits: (projectId: string, prNumber: number): Promise<NewCommitsCheck> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_CHECK_NEW_COMMITS, projectId, prNumber),
@@ -780,6 +807,11 @@ export const createGitHubAPI = (): GitHubAPI => ({
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.GITHUB_PR_REVIEW_ERROR, callback),
onPRReviewStateChange: (
callback: (key: string, state: PRReviewStatePayload) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.GITHUB_PR_REVIEW_STATE_CHANGE, callback),
onPRLogsUpdated: (
callback: (projectId: string, data: { prNumber: number; entryCount: number }) => void
): IpcListenerCleanup =>
@@ -419,19 +419,22 @@ export function PRDetail({
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const pollStart = Date.now();
let notified = false;
const pollForCompletion = async () => {
// Skip if we already notified (prevents duplicate notifications before React cleanup)
if (notified) return;
// Timeout: stop polling after 30 minutes to avoid indefinite polling
if (Date.now() - pollStart > MAX_POLL_DURATION_MS) {
usePRReviewStore.getState().setPRReviewResult(projectId, {
prNumber: pr.number,
repo: '',
success: false,
findings: [],
summary: '',
overallStatus: 'comment',
reviewedAt: new Date().toISOString(),
error: 'External review polling timed out after 30 minutes',
});
console.warn('[PRDetail] External review polling timed out after 30 minutes');
notified = true;
try {
// Notify main process so the XState actor transitions to error state
await window.electronAPI.github.notifyExternalReviewComplete(projectId, pr.number, null);
} catch {
// Non-critical — state manager timeout is a best-effort notification
}
return;
}
@@ -442,7 +445,9 @@ export function PRDetail({
// Otherwise this is a stale result from a previous review still on disk
// (in-progress results are intentionally NOT saved to disk).
if (startedAt && result.reviewedAt && new Date(result.reviewedAt) > new Date(startedAt)) {
usePRReviewStore.getState().setPRReviewResult(projectId, result);
notified = true;
// Notify main process so the XState actor transitions to completed state
await window.electronAPI.github.notifyExternalReviewComplete(projectId, pr.number, result);
}
}
} catch {
@@ -7,8 +7,6 @@ import type {
} from "../../../../preload/api/modules/github-api";
import {
usePRReviewStore,
startPRReview as storeStartPRReview,
startFollowupReview as storeStartFollowupReview,
} from "../../../stores/github";
// Re-export types for consumers
@@ -206,7 +204,7 @@ export function useGitHubPRs(
// Update store with loaded results
for (const reviewResult of Object.values(batchReviews)) {
if (reviewResult) {
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, {
usePRReviewStore.getState().setLoadedReviewResult(projectId, reviewResult, {
preserveNewCommitsCheck: true,
});
}
@@ -424,7 +422,7 @@ export function useGitHubPRs(
// Preserve newCommitsCheck when loading existing review from disk
usePRReviewStore
.getState()
.setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
.setLoadedReviewResult(projectId, result, { preserveNewCommitsCheck: true });
// Always check for new commits when selecting a reviewed PR
// This ensures fresh data even if we have a cached check from earlier in the session
@@ -512,7 +510,7 @@ export function useGitHubPRs(
// Update store with loaded results
for (const reviewResult of Object.values(batchReviews)) {
if (reviewResult) {
usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, {
usePRReviewStore.getState().setLoadedReviewResult(requestProjectId, reviewResult, {
preserveNewCommitsCheck: true,
});
}
@@ -536,8 +534,8 @@ export function useGitHubPRs(
(prNumber: number) => {
if (!projectId) return;
// Use the store function which handles both state and IPC
storeStartPRReview(projectId, prNumber);
// Main process handles XState state transition and subprocess launch
window.electronAPI.github.runPRReview(projectId, prNumber);
},
[projectId]
);
@@ -546,8 +544,8 @@ export function useGitHubPRs(
(prNumber: number) => {
if (!projectId) return;
// Use the store function which handles both state and IPC
storeStartFollowupReview(projectId, prNumber);
// Main process handles XState state transition and subprocess launch
window.electronAPI.github.runFollowupReview(projectId, prNumber);
},
[projectId]
);
@@ -577,15 +575,9 @@ export function useGitHubPRs(
if (!projectId) return false;
try {
// Main process kills subprocess and sends CANCEL_REVIEW to XState
// State update flows back via IPC (onPRReviewStateChange)
const success = await window.electronAPI.github.cancelPRReview(projectId, prNumber);
// Always update store state to exit the "reviewing" state
// Use different messages based on whether the process was found and killed
const message = success
? "Review cancelled by user"
: "Review stopped - process not found";
usePRReviewStore
.getState()
.setPRReviewError(projectId, prNumber, message);
return success;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel review");
@@ -617,7 +609,7 @@ export function useGitHubPRs(
// Preserve newCommitsCheck - posting doesn't change whether there are new commits
usePRReviewStore
.getState()
.setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
.setLoadedReviewResult(projectId, result, { preserveNewCommitsCheck: true });
}
}
return success;
@@ -699,7 +691,7 @@ export function useGitHubPRs(
const existingState = getPRReviewState(projectId, prNumber);
if (existingState?.result) {
// If we have the result loaded, update it with hasPostedFindings and postedAt
usePRReviewStore.getState().setPRReviewResult(
usePRReviewStore.getState().setLoadedReviewResult(
projectId,
{ ...existingState.result, hasPostedFindings: true, postedAt },
{ preserveNewCommitsCheck: true }
@@ -708,7 +700,7 @@ export function useGitHubPRs(
// If result not loaded yet (race condition), reload from disk to get updated state
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
if (result) {
usePRReviewStore.getState().setPRReviewResult(
usePRReviewStore.getState().setLoadedReviewResult(
projectId,
result,
{ preserveNewCommitsCheck: true }
@@ -218,6 +218,7 @@ const browserMockAPI: ElectronAPI = {
markReviewPosted: async () => true,
getPRReview: async () => null,
getPRReviewsBatch: async () => ({}),
notifyExternalReviewComplete: async () => {},
deletePRReview: async () => true,
checkNewCommits: async () => ({ hasNewCommits: false, newCommitCount: 0 }),
checkMergeReadiness: async () => ({ isDraft: false, mergeable: 'UNKNOWN' as const, isBehind: false, ciStatus: 'none' as const, blockers: [] }),
@@ -229,6 +230,7 @@ const browserMockAPI: ElectronAPI = {
onPRReviewProgress: () => () => {},
onPRReviewComplete: () => () => {},
onPRReviewError: () => () => {},
onPRReviewStateChange: () => () => {},
onPRLogsUpdated: () => () => {},
batchAutoFix: () => {},
getBatches: async () => [],
@@ -23,9 +23,7 @@ export {
export {
usePRReviewStore,
initializePRReviewListeners,
cleanupPRReviewListeners,
startPRReview,
startFollowupReview
cleanupPRReviewListeners
} from './pr-review-store';
import { initializePRReviewListeners as _initPRReviewListeners } from './pr-review-store';
import { cleanupPRReviewListeners as _cleanupPRReviewListeners } from './pr-review-store';
@@ -2,7 +2,8 @@ import { create } from 'zustand';
import type {
PRReviewProgress,
PRReviewResult,
NewCommitsCheck
NewCommitsCheck,
PRReviewStatePayload
} from '../../../preload/api/modules/github-api';
import type {
ChecksStatus,
@@ -44,14 +45,13 @@ interface PRReviewStoreState {
// Key: `${projectId}:${prNumber}`
prReviews: Record<string, PRReviewState>;
// Actions
startPRReview: (projectId: string, prNumber: number) => void;
startFollowupReview: (projectId: string, prNumber: number) => void;
setPRReviewProgress: (projectId: string, progress: PRReviewProgress) => void;
setPRReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => void;
setPRReviewError: (projectId: string, prNumber: number, error: string) => void;
// XState state change handler
handlePRReviewStateChange: (key: string, payload: PRReviewStatePayload) => void;
// Kept actions (not managed by XState)
/** Load a review result from disk into the store (not triggered by XState) */
setLoadedReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => void;
setNewCommitsCheck: (projectId: string, prNumber: number, check: NewCommitsCheck) => void;
clearPRReview: (projectId: string, prNumber: number) => void;
/** Update PR status from polling (CI checks, reviews, mergeability) */
setPRStatus: (projectId: string, prNumber: number, status: {
checksStatus: ChecksStatus;
@@ -61,8 +61,6 @@ interface PRReviewStoreState {
}) => void;
/** Clear PR status fields for a specific PR */
clearPRStatus: (projectId: string, prNumber: number) => void;
/** Start an external review (from PR list) - sets isReviewing and isExternalReview */
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => void;
// Selectors
getPRReviewState: (projectId: string, prNumber: number) => PRReviewState | null;
@@ -80,97 +78,57 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
// Initial state
prReviews: {},
// Actions
startPRReview: (projectId: string, prNumber: number) => set((state) => {
const key = `${projectId}:${prNumber}`;
const existing = state.prReviews[key];
return {
prReviews: {
...state.prReviews,
[key]: {
prNumber,
projectId,
isReviewing: true,
startedAt: new Date().toISOString(),
progress: null,
result: null,
previousResult: null,
error: null,
newCommitsCheck: existing?.newCommitsCheck ?? null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: false
}
}
};
}),
// XState state change handler — maps XState state/context back to PRReviewState shape
handlePRReviewStateChange: (key: string, payload: PRReviewStatePayload) => {
const isCompleted = payload.state === 'completed';
startFollowupReview: (projectId: string, prNumber: number) => set((state) => {
const key = `${projectId}:${prNumber}`;
const existing = state.prReviews[key];
set((state) => {
const existing = state.prReviews[key];
// Log warning if starting follow-up without a previous result
if (!existing?.result) {
console.warn(
`[PRReviewStore] Starting follow-up review for PR #${prNumber} without a previous result. ` +
`This may indicate the follow-up was triggered incorrectly.`
);
const updated: PRReviewState = {
prNumber: payload.prNumber,
projectId: payload.projectId,
isReviewing: payload.state === 'reviewing' || payload.state === 'externalReview',
startedAt: payload.startedAt,
progress: payload.progress,
result: payload.result,
previousResult: payload.previousResult,
error: payload.error,
isExternalReview: payload.isExternalReview,
// Preserve polling data — not managed by XState
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
// Preserve newCommitsCheck unless review completed (it was just reviewed)
newCommitsCheck: isCompleted ? null : (existing?.newCommitsCheck ?? null),
};
return {
prReviews: {
...state.prReviews,
[key]: updated,
},
};
});
// Trigger registered refresh callbacks when review completes
if (isCompleted) {
refreshCallbacks.forEach(callback => {
Promise.resolve(callback()).catch(error => {
console.error('[PRReviewStore] Error in refresh callback:', error);
});
});
}
},
return {
prReviews: {
...state.prReviews,
[key]: {
prNumber,
projectId,
isReviewing: true,
startedAt: new Date().toISOString(),
progress: null,
result: null,
previousResult: existing?.result ?? null, // Preserve for follow-up continuity
error: null,
newCommitsCheck: existing?.newCommitsCheck ?? null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: false
}
}
};
}),
setPRReviewProgress: (projectId: string, progress: PRReviewProgress) => set((state) => {
const key = `${projectId}:${progress.prNumber}`;
const existing = state.prReviews[key];
return {
prReviews: {
...state.prReviews,
[key]: {
prNumber: progress.prNumber,
projectId,
isReviewing: true,
startedAt: existing?.startedAt ?? null,
progress,
result: existing?.result ?? null,
previousResult: existing?.previousResult ?? null,
error: null,
newCommitsCheck: existing?.newCommitsCheck ?? null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: existing?.isExternalReview ?? false
}
}
};
}),
setPRReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => set((state) => {
setLoadedReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => set((state) => {
const key = `${projectId}:${result.prNumber}`;
const existing = state.prReviews[key];
// Don't overwrite active review state from XState
if (existing?.isReviewing) {
return state;
}
return {
prReviews: {
...state.prReviews,
@@ -178,47 +136,19 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
prNumber: result.prNumber,
projectId,
isReviewing: false,
startedAt: existing?.startedAt ?? null,
startedAt: null,
progress: null,
result,
previousResult: existing?.previousResult ?? null,
error: result.error ?? null,
// Clear new commits check when review completes (it was just reviewed)
// BUT preserve it during preload/refresh to avoid race condition
error: null,
newCommitsCheck: options?.preserveNewCommitsCheck ? (existing?.newCommitsCheck ?? null) : null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: existing?.isExternalReview ?? false
}
}
};
}),
setPRReviewError: (projectId: string, prNumber: number, error: string) => set((state) => {
const key = `${projectId}:${prNumber}`;
const existing = state.prReviews[key];
return {
prReviews: {
...state.prReviews,
[key]: {
prNumber,
projectId,
isReviewing: false,
startedAt: existing?.startedAt ?? null,
progress: null,
result: existing?.result ?? null,
previousResult: existing?.previousResult ?? null,
error,
newCommitsCheck: existing?.newCommitsCheck ?? null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: existing?.isExternalReview ?? false
}
}
isExternalReview: false,
},
},
};
}),
@@ -260,11 +190,6 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
};
}),
clearPRReview: (projectId: string, prNumber: number) => set((state) => {
const key = `${projectId}:${prNumber}`;
const { [key]: _, ...rest } = state.prReviews;
return { prReviews: rest };
}),
setPRStatus: (projectId: string, prNumber: number, status: {
checksStatus: ChecksStatus;
@@ -332,32 +257,6 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
};
}),
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => set((state) => {
const key = `${projectId}:${prNumber}`;
const existing = state.prReviews[key];
return {
prReviews: {
...state.prReviews,
[key]: {
prNumber,
projectId,
isReviewing: true,
startedAt: inProgressSince || new Date().toISOString(),
progress: null,
result: existing?.result ?? null,
previousResult: existing?.previousResult ?? null,
error: null,
newCommitsCheck: existing?.newCommitsCheck ?? null,
checksStatus: existing?.checksStatus ?? null,
reviewsStatus: existing?.reviewsStatus ?? null,
mergeableState: existing?.mergeableState ?? null,
lastPolled: existing?.lastPolled ?? null,
isExternalReview: true
}
}
};
}),
// Selectors
getPRReviewState: (projectId: string, prNumber: number) => {
const { prReviews } = get();
@@ -398,50 +297,18 @@ export function initializePRReviewListeners(): void {
const store = usePRReviewStore.getState();
// Check if GitHub PR Review API is available
if (!window.electronAPI?.github?.onPRReviewProgress) {
if (!window.electronAPI?.github?.onPRReviewStateChange) {
console.warn('[GitHub PR Store] GitHub PR Review API not available, skipping listener setup');
return;
}
// Listen for PR review progress events
// Each on* method returns a cleanup function — capture them for proper teardown
const cleanupProgress = window.electronAPI.github.onPRReviewProgress(
(projectId: string, progress: PRReviewProgress) => {
store.setPRReviewProgress(projectId, progress);
// Listen for XState state changes — single handler replaces progress/complete/error listeners
const cleanupStateChange = window.electronAPI.github.onPRReviewStateChange(
(key: string, payload: PRReviewStatePayload) => {
store.handlePRReviewStateChange(key, payload);
}
);
cleanupFunctions.push(cleanupProgress);
// Listen for PR review completion events
const cleanupComplete = window.electronAPI.github.onPRReviewComplete(
(projectId: string, result: PRReviewResult) => {
// When the backend detects an already-running review (e.g., started from another
// client or the PR list), it returns overallStatus === 'in_progress' instead of
// a real result. Transition to external-review-in-progress so the log polling
// activates and the UI shows the ongoing review.
if (result.overallStatus === 'in_progress') {
store.setExternalReviewInProgress(projectId, result.prNumber, result.inProgressSince);
return;
}
store.setPRReviewResult(projectId, result);
// Trigger all registered refresh callbacks when review completes
refreshCallbacks.forEach(callback => {
Promise.resolve(callback()).catch(error => {
console.error('[PRReviewStore] Error in refresh callback:', error);
});
});
}
);
cleanupFunctions.push(cleanupComplete);
// Listen for PR review error events
const cleanupError = window.electronAPI.github.onPRReviewError(
(projectId: string, data: { prNumber: number; error: string }) => {
store.setPRReviewError(projectId, data.prNumber, data.error);
}
);
cleanupFunctions.push(cleanupError);
cleanupFunctions.push(cleanupStateChange);
// Listen for GitHub auth changes - clear all PR review state when account changes
const cleanupAuthChanged = window.electronAPI.github.onGitHubAuthChanged(
@@ -457,7 +324,7 @@ export function initializePRReviewListeners(): void {
cleanupFunctions.push(cleanupAuthChanged);
// Listen for PR status polling updates (CI checks, reviews, mergeability)
window.electronAPI.github.onPRStatusUpdate(
const cleanupStatusUpdate = window.electronAPI.github.onPRStatusUpdate(
(update: PRStatusUpdate) => {
const { projectId, statuses } = update;
for (const status of statuses) {
@@ -470,6 +337,7 @@ export function initializePRReviewListeners(): void {
}
}
);
cleanupFunctions.push(cleanupStatusUpdate);
prReviewListenersInitialized = true;
}
@@ -490,22 +358,3 @@ export function cleanupPRReviewListeners(): void {
refreshCallbacks.clear();
prReviewListenersInitialized = false;
}
/**
* Start a PR review and track it in the store
*/
export function startPRReview(projectId: string, prNumber: number): void {
const store = usePRReviewStore.getState();
store.startPRReview(projectId, prNumber);
window.electronAPI.github.runPRReview(projectId, prNumber);
}
/**
* Start a follow-up PR review and track it in the store
* Uses startFollowupReview action to preserve previous result for continuity
*/
export function startFollowupReview(projectId: string, prNumber: number): void {
const store = usePRReviewStore.getState();
store.startFollowupReview(projectId, prNumber);
window.electronAPI.github.runFollowupReview(projectId, prNumber);
}
@@ -409,11 +409,13 @@ export const IPC_CHANNELS = {
GITHUB_PR_CHECK_MERGE_READINESS: 'github:pr:checkMergeReadiness',
GITHUB_PR_MARK_REVIEW_POSTED: 'github:pr:markReviewPosted',
GITHUB_PR_UPDATE_BRANCH: 'github:pr:updateBranch',
GITHUB_PR_NOTIFY_EXTERNAL_REVIEW_COMPLETE: 'github:pr:notifyExternalReviewComplete',
// GitHub PR Review events (main -> renderer)
GITHUB_PR_REVIEW_PROGRESS: 'github:pr:reviewProgress',
GITHUB_PR_REVIEW_COMPLETE: 'github:pr:reviewComplete',
GITHUB_PR_REVIEW_ERROR: 'github:pr:reviewError',
GITHUB_PR_REVIEW_STATE_CHANGE: 'github:pr:reviewStateChange',
GITHUB_PR_LOGS_UPDATED: 'github:pr:logsUpdated',
// GitHub PR Logs (for viewing AI review logs)
@@ -0,0 +1,352 @@
import { describe, it, expect } from 'vitest';
import { createActor } from 'xstate';
import { prReviewMachine, type PRReviewEvent } from '../pr-review-machine';
/**
* Helper to run a sequence of events and get the final state
*/
function runEvents(events: PRReviewEvent[]) {
const actor = createActor(prReviewMachine);
actor.start();
for (const event of events) {
actor.send(event);
}
const snapshot = actor.getSnapshot();
actor.stop();
return snapshot;
}
const mockResult = {
prNumber: 42,
repo: 'test/repo',
success: true,
findings: [],
summary: 'Test review',
overallStatus: 'approve' as const,
reviewedAt: new Date().toISOString(),
};
const mockProgress = {
phase: 'analyzing' as const,
prNumber: 42,
progress: 50,
message: 'Analyzing files...',
};
describe('prReviewMachine', () => {
describe('initial state', () => {
it('should start in idle state', () => {
const actor = createActor(prReviewMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('idle');
actor.stop();
});
it('should have null context initially', () => {
const actor = createActor(prReviewMachine);
actor.start();
const ctx = actor.getSnapshot().context;
expect(ctx.prNumber).toBeNull();
expect(ctx.projectId).toBeNull();
expect(ctx.progress).toBeNull();
expect(ctx.result).toBeNull();
expect(ctx.previousResult).toBeNull();
expect(ctx.error).toBeNull();
expect(ctx.isFollowup).toBe(false);
expect(ctx.isExternalReview).toBe(false);
actor.stop();
});
});
describe('happy path: idle -> reviewing -> completed', () => {
it('should transition through the standard review flow', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.result).toEqual(mockResult);
expect(snapshot.context.prNumber).toBe(42);
expect(snapshot.context.projectId).toBe('proj-1');
});
});
describe('follow-up review: completed -> reviewing (with previousResult)', () => {
it('should preserve previousResult when starting follow-up from completed', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'START_FOLLOWUP_REVIEW', prNumber: 42, projectId: 'proj-1', previousResult: mockResult },
]);
expect(snapshot.value).toBe('reviewing');
expect(snapshot.context.isFollowup).toBe(true);
expect(snapshot.context.previousResult).toEqual(mockResult);
expect(snapshot.context.result).toBeNull();
});
});
describe('error handling: reviewing -> error', () => {
it('should transition to error on REVIEW_ERROR', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'API failure' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('API failure');
expect(snapshot.context.progress).toBeNull();
});
});
describe('cancel flow: reviewing -> error', () => {
it('should set cancelled error message on CANCEL_REVIEW', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'CANCEL_REVIEW' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('Review cancelled by user');
});
});
describe('external review: reviewing -> externalReview -> completed', () => {
it('should transition through external review flow', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'DETECT_EXTERNAL_REVIEW' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.isExternalReview).toBe(true);
expect(snapshot.context.result).toEqual(mockResult);
});
it('should transition to error from externalReview', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'DETECT_EXTERNAL_REVIEW' },
{ type: 'REVIEW_ERROR', error: 'External review failed' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('External review failed');
});
});
describe('retry after error: error -> reviewing -> completed', () => {
it('should allow starting a new review from error state', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'Failed' },
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.error).toBeNull();
expect(snapshot.context.result).toEqual(mockResult);
});
it('should allow starting a follow-up review from error state with previousResult preserved', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'START_FOLLOWUP_REVIEW', prNumber: 42, projectId: 'proj-1', previousResult: mockResult },
{ type: 'REVIEW_ERROR', error: 'Follow-up failed' },
{ type: 'START_FOLLOWUP_REVIEW', prNumber: 42, projectId: 'proj-1', previousResult: mockResult },
]);
expect(snapshot.value).toBe('reviewing');
expect(snapshot.context.previousResult).toEqual(mockResult);
expect(snapshot.context.isFollowup).toBe(true);
});
});
describe('clear review', () => {
it('should clear from completed to idle', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'CLEAR_REVIEW' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.prNumber).toBeNull();
expect(snapshot.context.result).toBeNull();
});
it('should clear from error to idle', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'Failed' },
{ type: 'CLEAR_REVIEW' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.error).toBeNull();
});
it('should clear from reviewing to idle', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'CLEAR_REVIEW' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.prNumber).toBeNull();
expect(snapshot.context.projectId).toBeNull();
});
it('should clear from externalReview to idle', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'DETECT_EXTERNAL_REVIEW' },
{ type: 'CLEAR_REVIEW' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.prNumber).toBeNull();
expect(snapshot.context.isExternalReview).toBe(false);
});
});
describe('reject START_REVIEW when already reviewing', () => {
it('should stay in reviewing when START_REVIEW is sent again', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'START_REVIEW', prNumber: 99, projectId: 'proj-2' },
]);
expect(snapshot.value).toBe('reviewing');
expect(snapshot.context.prNumber).toBe(42);
});
});
describe('guard: reject SET_PROGRESS when not in reviewing state', () => {
it('should ignore SET_PROGRESS in idle state', () => {
const snapshot = runEvents([
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.progress).toBeNull();
});
it('should ignore SET_PROGRESS in completed state', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.progress).toBeNull();
});
it('should ignore SET_PROGRESS in error state', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'Failed' },
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.progress).toBeNull();
});
});
describe('context updates', () => {
it('should store progress during review', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.context.progress).toEqual(mockProgress);
});
it('should store result on completion', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
]);
expect(snapshot.context.result).toEqual(mockResult);
expect(snapshot.context.progress).toBeNull();
});
it('should store error on failure', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'Something broke' },
]);
expect(snapshot.context.error).toBe('Something broke');
expect(snapshot.context.progress).toBeNull();
});
it('should set startedAt on review start', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
]);
expect(snapshot.context.startedAt).toBeTruthy();
});
});
describe('follow-up context', () => {
it('should preserve previousResult in follow-up review', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'START_FOLLOWUP_REVIEW', prNumber: 42, projectId: 'proj-1', previousResult: mockResult },
{ type: 'REVIEW_COMPLETE', result: { ...mockResult, summary: 'Follow-up review' } },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.previousResult).toEqual(mockResult);
expect(snapshot.context.result?.summary).toBe('Follow-up review');
});
it('should clear previousResult on normal START_REVIEW from completed', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'START_REVIEW', prNumber: 43, projectId: 'proj-1' },
]);
expect(snapshot.context.previousResult).toBeNull();
expect(snapshot.context.isFollowup).toBe(false);
});
});
describe('stale events', () => {
it('should reject SET_PROGRESS in completed state', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_COMPLETE', result: mockResult },
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.value).toBe('completed');
expect(snapshot.context.progress).toBeNull();
});
it('should reject SET_PROGRESS in error state', () => {
const snapshot = runEvents([
{ type: 'START_REVIEW', prNumber: 42, projectId: 'proj-1' },
{ type: 'REVIEW_ERROR', error: 'Failed' },
{ type: 'SET_PROGRESS', progress: mockProgress },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.progress).toBeNull();
});
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import {
PR_REVIEW_STATE_NAMES,
PR_REVIEW_SETTLED_STATES,
mapPRReviewStateToLegacy,
} from '../pr-review-state-utils';
describe('pr-review-state-utils', () => {
describe('PR_REVIEW_STATE_NAMES', () => {
it('should contain all expected state names', () => {
expect(PR_REVIEW_STATE_NAMES).toEqual([
'idle', 'reviewing', 'externalReview', 'completed', 'error',
]);
});
});
describe('PR_REVIEW_SETTLED_STATES', () => {
it('should contain completed and error', () => {
expect(PR_REVIEW_SETTLED_STATES.has('completed')).toBe(true);
expect(PR_REVIEW_SETTLED_STATES.has('error')).toBe(true);
});
it('should not contain active states', () => {
expect(PR_REVIEW_SETTLED_STATES.has('idle')).toBe(false);
expect(PR_REVIEW_SETTLED_STATES.has('reviewing')).toBe(false);
expect(PR_REVIEW_SETTLED_STATES.has('externalReview')).toBe(false);
});
});
describe('mapPRReviewStateToLegacy', () => {
it('should map idle to idle', () => {
expect(mapPRReviewStateToLegacy('idle')).toBe('idle');
});
it('should map reviewing to reviewing', () => {
expect(mapPRReviewStateToLegacy('reviewing')).toBe('reviewing');
});
it('should map externalReview to reviewing', () => {
expect(mapPRReviewStateToLegacy('externalReview')).toBe('reviewing');
});
it('should map completed to completed', () => {
expect(mapPRReviewStateToLegacy('completed')).toBe('completed');
});
it('should map error to error', () => {
expect(mapPRReviewStateToLegacy('error')).toBe('error');
});
it('should map unknown states to idle', () => {
expect(mapPRReviewStateToLegacy('unknown')).toBe('idle');
});
});
});
@@ -8,6 +8,15 @@ export {
} from './task-state-utils';
export type { TaskStateName } from './task-state-utils';
export { prReviewMachine } from './pr-review-machine';
export type { PRReviewContext, PRReviewEvent } from './pr-review-machine';
export {
PR_REVIEW_STATE_NAMES,
PR_REVIEW_SETTLED_STATES,
mapPRReviewStateToLegacy,
} from './pr-review-state-utils';
export type { PRReviewStateName } from './pr-review-state-utils';
export { terminalMachine } from './terminal-machine';
export type { TerminalContext, TerminalEvent } from './terminal-machine';
@@ -0,0 +1,190 @@
import { assign, createMachine } from 'xstate';
import type { PRReviewProgress, PRReviewResult } from '../../preload/api/modules/github-api';
export interface PRReviewContext {
prNumber: number | null;
projectId: string | null;
startedAt: string | null;
isFollowup: boolean;
progress: PRReviewProgress | null;
result: PRReviewResult | null;
previousResult: PRReviewResult | null;
error: string | null;
isExternalReview: boolean;
}
export type PRReviewEvent =
| { type: 'START_REVIEW'; prNumber: number; projectId: string }
| { type: 'START_FOLLOWUP_REVIEW'; prNumber: number; projectId: string; previousResult: PRReviewResult }
| { type: 'SET_PROGRESS'; progress: PRReviewProgress }
| { type: 'REVIEW_COMPLETE'; result: PRReviewResult }
| { type: 'REVIEW_ERROR'; error: string }
| { type: 'CANCEL_REVIEW' }
| { type: 'DETECT_EXTERNAL_REVIEW' }
| { type: 'CLEAR_REVIEW' };
const initialContext: PRReviewContext = {
prNumber: null,
projectId: null,
startedAt: null,
isFollowup: false,
progress: null,
result: null,
previousResult: null,
error: null,
isExternalReview: false,
};
export const prReviewMachine = createMachine(
{
id: 'prReview',
initial: 'idle',
types: {} as {
context: PRReviewContext;
events: PRReviewEvent;
},
context: { ...initialContext },
states: {
idle: {
on: {
START_REVIEW: {
target: 'reviewing',
actions: 'setReviewStart',
},
START_FOLLOWUP_REVIEW: {
target: 'reviewing',
actions: 'setFollowupReviewStart',
},
},
},
reviewing: {
on: {
SET_PROGRESS: {
actions: 'setProgress',
},
REVIEW_COMPLETE: {
target: 'completed',
actions: 'setResult',
},
REVIEW_ERROR: {
target: 'error',
actions: 'setError',
},
CANCEL_REVIEW: {
target: 'error',
actions: 'setCancelledError',
},
CLEAR_REVIEW: {
target: 'idle',
actions: 'clearContext',
},
DETECT_EXTERNAL_REVIEW: {
target: 'externalReview',
actions: 'setExternalReview',
},
},
},
externalReview: {
on: {
REVIEW_COMPLETE: {
target: 'completed',
actions: 'setResult',
},
REVIEW_ERROR: {
target: 'error',
actions: 'setError',
},
CANCEL_REVIEW: {
target: 'error',
actions: 'setCancelledError',
},
CLEAR_REVIEW: {
target: 'idle',
actions: 'clearContext',
},
},
},
completed: {
on: {
START_REVIEW: {
target: 'reviewing',
actions: 'setReviewStart',
},
START_FOLLOWUP_REVIEW: {
target: 'reviewing',
actions: 'setFollowupReviewStart',
},
REVIEW_COMPLETE: {
actions: 'setResult',
},
CLEAR_REVIEW: {
target: 'idle',
actions: 'clearContext',
},
},
},
error: {
on: {
START_REVIEW: {
target: 'reviewing',
actions: 'setReviewStart',
},
START_FOLLOWUP_REVIEW: {
target: 'reviewing',
actions: 'setFollowupReviewStart',
},
CLEAR_REVIEW: {
target: 'idle',
actions: 'clearContext',
},
},
},
},
},
{
actions: {
setReviewStart: assign({
prNumber: ({ event }) => (event as { prNumber: number }).prNumber,
projectId: ({ event }) => (event as { projectId: string }).projectId,
startedAt: () => new Date().toISOString(),
isFollowup: () => false,
progress: () => null,
result: () => null,
previousResult: () => null,
error: () => null,
isExternalReview: () => false,
}),
setFollowupReviewStart: assign({
prNumber: ({ event }) => (event as { prNumber: number }).prNumber,
projectId: ({ event }) => (event as { projectId: string }).projectId,
startedAt: () => new Date().toISOString(),
isFollowup: () => true,
progress: () => null,
result: () => null,
previousResult: ({ event }) => (event as { previousResult: PRReviewResult }).previousResult,
error: () => null,
isExternalReview: () => false,
}),
setProgress: assign({
progress: ({ event }) => (event as { progress: PRReviewProgress }).progress,
}),
setResult: assign({
result: ({ event }) => (event as { result: PRReviewResult }).result,
progress: () => null,
}),
setError: assign({
error: ({ event }) => (event as { error: string }).error,
progress: () => null,
}),
setCancelledError: assign({
error: () => 'Review cancelled by user',
progress: () => null,
}),
setExternalReview: assign({
isExternalReview: () => true,
progress: () => null,
}),
clearContext: assign(() => ({ ...initialContext })),
},
}
);
@@ -0,0 +1,52 @@
/**
* Shared XState PR review state utilities.
*
* Provides type-safe state names, settled states, and legacy status conversion
* derived from the PR review machine definition.
*/
/**
* All XState PR review state names.
*
* IMPORTANT: These must match the state keys in pr-review-machine.ts.
* If you add/remove a state in the machine, update this array.
*/
export const PR_REVIEW_STATE_NAMES = [
'idle', 'reviewing', 'externalReview', 'completed', 'error'
] as const;
export type PRReviewStateName = typeof PR_REVIEW_STATE_NAMES[number];
/**
* XState states where the PR review has "settled" — the review lifecycle
* has reached a terminal or resting state. Progress events should not
* overwrite these states.
*/
export const PR_REVIEW_SETTLED_STATES: ReadonlySet<string> = new Set<PRReviewStateName>([
'completed', 'error'
]);
/**
* Legacy review status values used by the existing Zustand store.
*/
type LegacyPRReviewStatus = 'idle' | 'reviewing' | 'completed' | 'error';
/**
* Convert XState PR review state to legacy status for backward compatibility.
*/
export function mapPRReviewStateToLegacy(state: string): LegacyPRReviewStatus {
switch (state) {
case 'idle':
return 'idle';
case 'reviewing':
return 'reviewing';
case 'externalReview':
return 'reviewing';
case 'completed':
return 'completed';
case 'error':
return 'error';
default:
return 'idle';
}
}