diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3a..1c9a29fc 100644 --- a/apps/frontend/src/shared/state-machines/index.ts +++ b/apps/frontend/src/shared/state-machines/index.ts @@ -7,3 +7,12 @@ export { mapStateToLegacy, } 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'; diff --git a/apps/frontend/src/shared/state-machines/pr-review-machine.ts b/apps/frontend/src/shared/state-machines/pr-review-machine.ts new file mode 100644 index 00000000..fd14698a --- /dev/null +++ b/apps/frontend/src/shared/state-machines/pr-review-machine.ts @@ -0,0 +1,177 @@ +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: { + START_REVIEW: { + target: 'reviewing', + guard: 'isNotAlreadyReviewing', + }, + SET_PROGRESS: { + actions: 'setProgress', + }, + REVIEW_COMPLETE: { + target: 'completed', + actions: 'setResult', + }, + REVIEW_ERROR: { + target: 'error', + actions: 'setError', + }, + CANCEL_REVIEW: { + target: 'error', + actions: 'setCancelledError', + }, + DETECT_EXTERNAL_REVIEW: { + target: 'externalReview', + actions: 'setExternalReview', + }, + }, + }, + externalReview: { + on: { + REVIEW_COMPLETE: { + target: 'completed', + actions: 'setResult', + }, + REVIEW_ERROR: { + target: 'error', + actions: 'setError', + }, + }, + }, + completed: { + on: { + START_REVIEW: { + target: 'reviewing', + actions: 'setReviewStart', + }, + START_FOLLOWUP_REVIEW: { + target: 'reviewing', + actions: 'setFollowupReviewStart', + }, + CLEAR_REVIEW: { + target: 'idle', + actions: 'clearContext', + }, + }, + }, + error: { + on: { + START_REVIEW: { + target: 'reviewing', + actions: 'setReviewStart', + }, + CLEAR_REVIEW: { + target: 'idle', + actions: 'clearContext', + }, + }, + }, + }, + }, + { + guards: { + isNotAlreadyReviewing: () => false, + }, + 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, + }), + clearContext: assign(() => ({ ...initialContext })), + }, + } +); diff --git a/apps/frontend/src/shared/state-machines/pr-review-state-utils.ts b/apps/frontend/src/shared/state-machines/pr-review-state-utils.ts new file mode 100644 index 00000000..a45f41a4 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/pr-review-state-utils.ts @@ -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 = new Set([ + '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'; + } +}