diff --git a/apps/frontend/src/main/pr-review-state-manager.ts b/apps/frontend/src/main/pr-review-state-manager.ts new file mode 100644 index 00000000..01ba68fb --- /dev/null +++ b/apps/frontend/src/main/pr-review-state-manager.ts @@ -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; + +/** + * 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(); + private lastStateByPR = new Map(); + 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 | 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 | 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 + ); + } +}