auto-claude: subtask-2-2 - Create PRReviewStateManager class in Electron main process

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
AndyMik90
2026-02-17 15:32:35 +01:00
co-authored by Claude Opus 4.6
parent b4264ae95b
commit bfdbdb5f22
@@ -0,0 +1,162 @@
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 } 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 {
const actor = this.getActor(projectId, prNumber);
if (!actor) return;
// 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) {
actor.send({ type: 'CLEAR_REVIEW' } satisfies PRReviewEvent);
actor.stop();
this.actors.delete(key);
}
this.lastStateByPR.delete(key);
}
handleAuthChange(): void {
for (const [key, actor] of this.actors) {
actor.stop();
// Emit cleared (idle) state to renderer for each PR
this.emitStateToRenderer(key, 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 context = snapshot ? snapshot.context : null;
safeSendToRenderer(
this.getMainWindow,
IPC_CHANNELS.GITHUB_PR_REVIEW_STATE_CHANGE,
key,
stateValue,
context
);
}
}