feat(issues): rewrite investigation store with multi-issue keyed state (F2)

Complete rewrite of investigation-store.ts modeled after pr-review-store.ts. Adds per-issue state keyed by projectId:issueNumber, derived 8-state machine, global IPC listeners with init/cleanup lifecycle, and settings sub-state. Preserves legacy single-issue state for backwards compat with existing hook.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-13 18:28:17 +01:00
co-authored by Claude Opus 4.6
parent 65bcb72ca6
commit 4e02ea1bc1
2 changed files with 375 additions and 20 deletions
@@ -5,7 +5,7 @@
* Previously managed by a single monolithic store, now split into:
* - Issues Store: Issue data and filtering
* - PR Review Store: Pull request review state and progress
* - Investigation Store: Issue investigation workflow
* - Investigation Store: Issue investigation workflow (new multi-issue system + legacy compat)
* - Sync Status Store: GitHub connection status
*/
@@ -33,8 +33,15 @@ import { cleanupPRReviewListeners as _cleanupPRReviewListeners } from './pr-revi
// Investigation Store
export {
useInvestigationStore,
investigateGitHubIssue
initializeInvestigationListeners,
cleanupInvestigationListeners,
startIssueInvestigation,
cancelIssueInvestigation,
investigateGitHubIssue,
type IssueInvestigationState
} from './investigation-store';
import { initializeInvestigationListeners as _initInvestigationListeners } from './investigation-store';
import { cleanupInvestigationListeners as _cleanupInvestigationListeners } from './investigation-store';
// Sync Status Store
export {
@@ -48,7 +55,7 @@ export {
*/
export function initializeGitHubListeners(): void {
_initPRReviewListeners();
// Add other global listeners here as needed
_initInvestigationListeners();
}
/**
@@ -57,6 +64,7 @@ export function initializeGitHubListeners(): void {
*/
export function cleanupGitHubListeners(): void {
_cleanupPRReviewListeners();
_cleanupInvestigationListeners();
}
// Re-export types for convenience
@@ -69,5 +77,10 @@ export type {
GitHubIssue,
GitHubSyncStatus,
GitHubInvestigationStatus,
GitHubInvestigationResult
GitHubInvestigationResult,
InvestigationState,
InvestigationProgress,
InvestigationResult,
InvestigationReport,
InvestigationSettings
} from '@shared/types';
@@ -1,42 +1,384 @@
import { create } from 'zustand';
import type {
GitHubInvestigationStatus,
GitHubInvestigationResult
GitHubInvestigationResult,
InvestigationProgress,
InvestigationResult,
InvestigationReport,
InvestigationDismissReason,
InvestigationSettings,
InvestigationState
} from '@shared/types';
interface InvestigationState {
// Investigation state
// ============================================
// Per-Issue Investigation State
// ============================================
/**
* Investigation state for a single issue.
* Keyed by `${projectId}:${issueNumber}` in the store.
*/
export interface IssueInvestigationState {
issueNumber: number;
projectId: string;
isInvestigating: boolean;
/** Real-time progress during investigation */
progress: InvestigationProgress | null;
/** Final investigation report */
report: InvestigationReport | null;
/** Previous report (kept during re-investigation for continuity) */
previousReport: InvestigationReport | null;
/** Error message if investigation failed */
error: string | null;
/** Pre-allocated spec ID for task creation */
specId: string | null;
/** Dismiss reason if issue was dismissed */
dismissReason: InvestigationDismissReason | null;
/** GitHub comment ID if results were posted */
githubCommentId: number | null;
/** Timestamp when investigation started */
startedAt: string | null;
/** Timestamp when investigation completed */
completedAt: string | null;
}
// ============================================
// Store Interface
// ============================================
interface InvestigationStoreState {
// Per-issue investigation state
// Key: `${projectId}:${issueNumber}`
investigations: Record<string, IssueInvestigationState>;
// Settings (per project, keyed by projectId)
settings: Record<string, InvestigationSettings>;
// ---- Legacy state (backwards compat with useGitHubInvestigation hook) ----
investigationStatus: GitHubInvestigationStatus;
lastInvestigationResult: GitHubInvestigationResult | null;
// Actions
setInvestigationStatus: (status: GitHubInvestigationStatus) => void;
setInvestigationResult: (result: GitHubInvestigationResult | null) => void;
clearInvestigation: () => void;
// ---- New system actions ----
startInvestigation: (projectId: string, issueNumber: number) => void;
setProgress: (projectId: string, progress: InvestigationProgress) => void;
setResult: (projectId: string, result: InvestigationResult) => void;
setError: (projectId: string, issueNumber: number, error: string) => void;
dismiss: (projectId: string, issueNumber: number, reason: InvestigationDismissReason) => void;
clearIssueInvestigation: (projectId: string, issueNumber: number) => void;
setSettings: (projectId: string, settings: InvestigationSettings) => void;
// ---- Selectors ----
getInvestigationState: (projectId: string, issueNumber: number) => IssueInvestigationState | null;
getDerivedState: (projectId: string, issueNumber: number) => InvestigationState;
getActiveInvestigations: (projectId: string) => IssueInvestigationState[];
getSettings: (projectId: string) => InvestigationSettings | null;
}
export const useInvestigationStore = create<InvestigationState>((set) => ({
// ============================================
// Store Implementation
// ============================================
export const useInvestigationStore = create<InvestigationStoreState>((set, get) => ({
// Initial state
investigationStatus: {
phase: 'idle',
progress: 0,
message: ''
},
investigations: {},
settings: {},
// Legacy state
investigationStatus: { phase: 'idle', progress: 0, message: '' },
lastInvestigationResult: null,
// Actions
// Legacy actions (backwards compat)
setInvestigationStatus: (investigationStatus) => set({ investigationStatus }),
setInvestigationResult: (lastInvestigationResult) => set({ lastInvestigationResult }),
clearInvestigation: () => set({
investigationStatus: { phase: 'idle', progress: 0, message: '' },
lastInvestigationResult: null
})
}),
// ---- New system actions ----
startInvestigation: (projectId: string, issueNumber: number) => set((state) => {
const key = `${projectId}:${issueNumber}`;
const existing = state.investigations[key];
return {
investigations: {
...state.investigations,
[key]: {
issueNumber,
projectId,
isInvestigating: true,
progress: null,
report: null,
previousReport: existing?.report ?? null,
error: null,
specId: existing?.specId ?? null,
dismissReason: null, // clear dismiss on re-investigation
githubCommentId: existing?.githubCommentId ?? null,
startedAt: new Date().toISOString(),
completedAt: null
}
}
};
}),
setProgress: (projectId: string, progress: InvestigationProgress) => set((state) => {
const key = `${projectId}:${progress.issueNumber}`;
const existing = state.investigations[key];
return {
investigations: {
...state.investigations,
[key]: {
issueNumber: progress.issueNumber,
projectId,
isInvestigating: true,
progress,
report: existing?.report ?? null,
previousReport: existing?.previousReport ?? null,
error: null,
specId: existing?.specId ?? null,
dismissReason: existing?.dismissReason ?? null,
githubCommentId: existing?.githubCommentId ?? null,
startedAt: existing?.startedAt ?? null,
completedAt: null
}
}
};
}),
setResult: (projectId: string, result: InvestigationResult) => set((state) => {
const key = `${projectId}:${result.issueNumber}`;
const existing = state.investigations[key];
return {
investigations: {
...state.investigations,
[key]: {
issueNumber: result.issueNumber,
projectId,
isInvestigating: false,
progress: null,
report: result.report,
previousReport: existing?.previousReport ?? null,
error: null,
specId: result.specId ?? existing?.specId ?? null,
dismissReason: existing?.dismissReason ?? null,
githubCommentId: result.githubCommentId ?? existing?.githubCommentId ?? null,
startedAt: existing?.startedAt ?? null,
completedAt: result.completedAt
}
}
};
}),
setError: (projectId: string, issueNumber: number, error: string) => set((state) => {
const key = `${projectId}:${issueNumber}`;
const existing = state.investigations[key];
return {
investigations: {
...state.investigations,
[key]: {
issueNumber,
projectId,
isInvestigating: false,
progress: null,
report: existing?.report ?? null,
previousReport: existing?.previousReport ?? null,
error,
specId: existing?.specId ?? null,
dismissReason: existing?.dismissReason ?? null,
githubCommentId: existing?.githubCommentId ?? null,
startedAt: existing?.startedAt ?? null,
completedAt: null
}
}
};
}),
dismiss: (projectId: string, issueNumber: number, reason: InvestigationDismissReason) => set((state) => {
const key = `${projectId}:${issueNumber}`;
const existing = state.investigations[key];
if (!existing) return state;
return {
investigations: {
...state.investigations,
[key]: {
...existing,
dismissReason: reason
}
}
};
}),
clearIssueInvestigation: (projectId: string, issueNumber: number) => set((state) => {
const key = `${projectId}:${issueNumber}`;
const { [key]: _, ...rest } = state.investigations;
return { investigations: rest };
}),
setSettings: (projectId: string, settings: InvestigationSettings) => set((state) => ({
settings: {
...state.settings,
[projectId]: settings
}
})),
// ---- Selectors ----
getInvestigationState: (projectId: string, issueNumber: number) => {
const { investigations } = get();
const key = `${projectId}:${issueNumber}`;
return investigations[key] ?? null;
},
/**
* Compute the derived investigation state for an issue.
* This is the 8-state machine from the design doc, fully derived
* from investigation data (never manually set).
*/
getDerivedState: (projectId: string, issueNumber: number): InvestigationState => {
const { investigations } = get();
const key = `${projectId}:${issueNumber}`;
const inv = investigations[key];
if (!inv) return 'new';
if (inv.isInvestigating) return 'investigating';
if (inv.error) return 'failed';
if (inv.report?.likelyResolved) return 'resolved';
if (inv.report && !inv.specId) return 'findings_ready';
if (inv.specId) return 'task_created';
return 'new';
},
getActiveInvestigations: (projectId: string) => {
const { investigations } = get();
return Object.values(investigations).filter(
inv => inv.projectId === projectId && inv.isInvestigating
);
},
getSettings: (projectId: string) => {
const { settings } = get();
return settings[projectId] ?? null;
}
}));
// ============================================
// Global IPC Listeners
// ============================================
let investigationListenersInitialized = false;
let cleanupFunctions: (() => void)[] = [];
/**
* Start investigating a GitHub issue
* Initialize global IPC listeners for investigation events.
* Call this once at app startup to ensure events are captured
* regardless of which component is mounted.
*/
export function initializeInvestigationListeners(): void {
if (investigationListenersInitialized) return;
// Check if the new investigation API is available
if (!window.electronAPI?.github?.onInvestigationProgress) {
console.warn('[InvestigationStore] Investigation API not available, skipping listener setup');
return;
}
const store = useInvestigationStore.getState();
// Listen for investigation progress events
const cleanupProgress = window.electronAPI.github.onInvestigationProgress(
(projectId: string, progress: InvestigationProgress) => {
store.setProgress(projectId, progress);
}
);
cleanupFunctions.push(cleanupProgress);
// Listen for investigation completion events
const cleanupComplete = window.electronAPI.github.onInvestigationComplete(
(projectId: string, result: InvestigationResult) => {
store.setResult(projectId, result);
}
);
cleanupFunctions.push(cleanupComplete);
// Listen for investigation error events
const cleanupError = window.electronAPI.github.onInvestigationError(
(projectId: string, error: string) => {
// We need the issueNumber from the error; parse from active investigations
const active = store.getActiveInvestigations(projectId);
// If there's only one active, use that. Otherwise, this error is ambiguous.
// The IPC handler should include issueNumber in a future iteration,
// but for now we mark all active investigations as errored.
for (const inv of active) {
store.setError(projectId, inv.issueNumber, error);
}
}
);
cleanupFunctions.push(cleanupError);
// Listen for GitHub auth changes - clear all investigation state
if (window.electronAPI.github.onGitHubAuthChanged) {
const cleanupAuthChanged = window.electronAPI.github.onGitHubAuthChanged(
() => {
useInvestigationStore.setState({ investigations: {}, settings: {} });
}
);
cleanupFunctions.push(cleanupAuthChanged);
}
investigationListenersInitialized = true;
}
/**
* Cleanup investigation listeners.
* Call this during app unmount or hot-reload.
*/
export function cleanupInvestigationListeners(): void {
for (const cleanup of cleanupFunctions) {
try {
cleanup();
} catch {
// Ignore cleanup errors
}
}
cleanupFunctions = [];
investigationListenersInitialized = false;
}
// ============================================
// Action helpers (called from outside the store)
// ============================================
/**
* Start investigating a GitHub issue via the new investigation system.
*/
export function startIssueInvestigation(
projectId: string,
issueNumber: number
): void {
const store = useInvestigationStore.getState();
store.startInvestigation(projectId, issueNumber);
window.electronAPI.github.startInvestigation(projectId, issueNumber);
}
/**
* Cancel a running investigation.
*/
export function cancelIssueInvestigation(
projectId: string,
issueNumber: number
): void {
const store = useInvestigationStore.getState();
// Mark as not investigating immediately
store.setError(projectId, issueNumber, 'Investigation cancelled');
window.electronAPI.github.cancelInvestigation(projectId, issueNumber);
}
/**
* Start investigating a GitHub issue (legacy function, kept for backwards compat).
* Uses the old investigateGitHubIssue IPC channel.
*/
export function investigateGitHubIssue(
projectId: string,