import { create } from 'zustand'; import { getRuntimeKey } from '@/lib/runtime-switch'; import type { GitHubAPI, GitHubPullRequestContextResult, GitHubRepoSelector, } from '@/lib/api/types'; const PR_CONTEXT_TTL_MS = 30_000; const PR_CONTEXT_MAX_ENTRIES = 20; // JSON tuple key: runtime-scoped (a runtime switch must never serve another // backend's data) and parseable, so invalidation can compare the directory // exactly instead of by string prefix. export const getPrContextKey = (directory: string, number: number): string => JSON.stringify([getRuntimeKey(), directory, number]); const parsePrContextKey = (key: string): { runtimeKey: string; directory: string; number: number } | null => { try { const parsed: unknown = JSON.parse(key); if (!Array.isArray(parsed) || parsed.length !== 3) { return null; } const [runtimeKey, directory, number] = parsed; if (typeof runtimeKey !== 'string' || typeof directory !== 'string' || typeof number !== 'number') { return null; } return { runtimeKey, directory, number }; } catch { return null; } }; type PrContextEntry = { result: GitHubPullRequestContextResult | null; /** Whether `result` was fetched with check details included. */ hasCheckDetails: boolean; fetchedAt: number; isLoading: boolean; error: string | null; }; type EnsureOptions = { includeCheckDetails?: boolean; sourceRepo?: GitHubRepoSelector | null; force?: boolean; }; interface PrContextStoreState { entries: Record; /** * Fetch (or reuse) the PR context for directory#number. Deduplicates * concurrent callers and serves a fresh-enough cached result; a * details-inclusive result satisfies detail-free requests. */ ensure: ( github: GitHubAPI, directory: string, number: number, options?: EnsureOptions, ) => Promise; invalidate: (directory: string, number?: number) => void; } const createEntry = (): PrContextEntry => ({ result: null, hasCheckDetails: false, fetchedAt: 0, isLoading: false, error: null, }); const inFlight = new Map>(); const boundEntries = (entries: Record): Record => { const all = Object.entries(entries); if (all.length <= PR_CONTEXT_MAX_ENTRIES) return entries; return Object.fromEntries(all .sort(([, left], [, right]) => right.fetchedAt - left.fetchedAt) .slice(0, PR_CONTEXT_MAX_ENTRIES)); }; export const usePrContextStore = create()((set, get) => ({ entries: {}, ensure: async (github, directory, number, options) => { const key = getPrContextKey(directory, number); const includeCheckDetails = options?.includeCheckDetails ?? false; const existing = get().entries[key]; const isFresh = existing && existing.result && Date.now() - existing.fetchedAt < PR_CONTEXT_TTL_MS && (existing.hasCheckDetails || !includeCheckDetails); if (!options?.force && isFresh) { return existing.result; } // A detail-inclusive request in flight satisfies everyone; a detail-free // one only satisfies detail-free callers. const inFlightKey = `${key}::${includeCheckDetails ? 'details' : 'plain'}`; const detailedInFlight = inFlight.get(`${key}::details`); const pending = detailedInFlight ?? (includeCheckDetails ? null : inFlight.get(inFlightKey)); if (pending && !options?.force) { return pending; } const request = (async (): Promise => { set((state) => ({ entries: { ...state.entries, [key]: { ...(state.entries[key] ?? createEntry()), isLoading: true, error: null }, }, })); try { const result = await github.prContext(directory, number, { includeDiff: false, includeCheckDetails, sourceRepo: options?.sourceRepo ?? null, }); set((state) => ({ entries: boundEntries({ ...state.entries, [key]: { result, hasCheckDetails: includeCheckDetails, fetchedAt: Date.now(), isLoading: false, error: null, }, }), })); return result; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to load pull request context'; set((state) => ({ entries: { ...state.entries, [key]: { ...(state.entries[key] ?? createEntry()), isLoading: false, error: message }, }, })); return null; } finally { inFlight.delete(inFlightKey); } })(); inFlight.set(inFlightKey, request); return request; }, invalidate: (directory, number) => { set((state) => { const next: Record = {}; for (const [key, entry] of Object.entries(state.entries)) { const parsed = parsePrContextKey(key); const matches = Boolean( parsed && parsed.directory === directory && (number == null || parsed.number === number), ); if (!matches) { next[key] = entry; } } return { entries: next }; }); }, }));