Files
openchamber/packages/ui/src/stores/useGitHubPrStatusStore.ts
T
Bohdan Triapitsyn f8beffce32 perf: reduce startup request fanout
Avoid repeated sidebar session and worktree refreshes
Limit concurrent GitHub PR status checks
Reuse cached worktree root resolution
2026-05-25 11:58:59 +03:00

730 lines
24 KiB
TypeScript

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
import { mapWithConcurrency } from '@/lib/concurrency';
import { getSafeStorage } from './utils/safeStorage';
const PR_REVALIDATE_TTL_MS = 90_000;
const PR_REVALIDATE_INTERVAL_MS = 15_000;
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const;
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
const PR_STATUS_REFRESH_CONCURRENCY = 4;
const PR_PERSIST_TTL_MS = 12 * 60 * 60_000;
const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status';
const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged';
const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
const checks = status?.checks;
if (!checks) {
return false;
}
return checks.state === 'pending' || checks.pending > 0;
};
export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => {
void remoteName;
return `${directory}::${branch}`;
};
type RefreshOptions = {
force?: boolean;
onlyExistingPr?: boolean;
silent?: boolean;
markInitialResolved?: boolean;
};
type PrTrackingTarget = {
directory: string;
branch: string;
remoteName?: string | null;
};
type PrRuntimeParams = {
directory: string;
branch: string;
remoteName: string | null;
canShow: boolean;
github?: RuntimeAPIs['github'];
githubAuthChecked: boolean;
githubConnected: boolean | null;
};
type PrEntryIdentity = {
directory: string;
branch: string;
remoteName: string | null;
};
type PrStatusEntry = {
status: GitHubPullRequestStatus | null;
isLoading: boolean;
error: string | null;
isInitialStatusResolved: boolean;
lastRefreshAt: number;
lastDiscoveryPollAt: number;
watchers: number;
params: PrRuntimeParams | null;
identity: PrEntryIdentity | null;
resolvedRemoteName: string | null;
};
type PersistedPrStatusEntry = Pick<
PrStatusEntry,
'status' | 'isInitialStatusResolved' | 'lastRefreshAt' | 'lastDiscoveryPollAt' | 'identity' | 'resolvedRemoteName'
>;
type GitHubPrStatusStore = {
entries: Record<string, PrStatusEntry>;
activeRequestCount: number;
totalRequestCount: number;
ensureEntry: (key: string) => void;
setParams: (key: string, params: PrRuntimeParams) => void;
startWatching: (key: string) => void;
stopWatching: (key: string) => void;
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise<void>;
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
};
const timers = new Map<string, number>();
const bootstrapTimers = new Map<string, number[]>();
const inFlightBySignature = new Set<string>();
const lastRefreshBySignature = new Map<string, number>();
const createEntry = (): PrStatusEntry => ({
status: null,
isLoading: false,
error: null,
isInitialStatusResolved: false,
lastRefreshAt: 0,
lastDiscoveryPollAt: 0,
watchers: 0,
params: null,
identity: null,
resolvedRemoteName: null,
});
const getIdentityFromEntry = (entry: PrStatusEntry | null | undefined): PrEntryIdentity | null => {
if (entry?.params?.directory && entry.params.branch) {
return {
directory: entry.params.directory,
branch: entry.params.branch,
remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null,
};
}
if (!entry?.identity?.directory || !entry.identity.branch) {
return null;
}
return entry.identity;
};
const getSignatureFromEntry = (entry: PrStatusEntry | null | undefined): string | null => {
const identity = getIdentityFromEntry(entry);
if (!identity?.directory || !identity.branch) {
return null;
}
return `${identity.directory}::${identity.branch}`;
};
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
return Object.entries(entries)
.filter(([, entry]) => getSignatureFromEntry(entry) === signature)
.map(([key]) => key);
};
const mergeParams = (entry: PrStatusEntry, next: PrRuntimeParams): PrStatusEntry => {
const remoteName = next.remoteName ?? entry.params?.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null;
return {
...entry,
params: entry.params
? {
...entry.params,
...next,
remoteName,
}
: {
...next,
remoteName,
},
identity: {
directory: next.directory,
branch: next.branch,
remoteName,
},
};
};
const getFetchableParams = (entry: PrStatusEntry | null | undefined): PrRuntimeParams | null => {
if (!entry?.params?.canShow || !entry.params.github?.prStatus) {
return null;
}
return {
...entry.params,
remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null,
};
};
const pickFetchParamsForSignature = (
entries: Record<string, PrStatusEntry>,
signature: string,
preferredKey: string,
): PrRuntimeParams | null => {
const keys = getKeysBySignature(entries, signature);
const candidates = keys
.map((key) => getFetchableParams(entries[key]))
.filter((params): params is PrRuntimeParams => Boolean(params));
if (candidates.length === 0) {
return null;
}
const preferred = getFetchableParams(entries[preferredKey]);
if (preferred && getSignatureFromEntry(entries[preferredKey]) === signature) {
return preferred;
}
const withResolvedRemote = keys
.map((key) => entries[key])
.find((entry) => Boolean(entry?.resolvedRemoteName && getFetchableParams(entry)));
if (withResolvedRemote) {
return getFetchableParams(withResolvedRemote);
}
const withRemote = candidates.find((params) => Boolean(params.remoteName));
if (withRemote) {
return withRemote;
}
return candidates[0] ?? null;
};
const toPersistedEntry = (entry: PrStatusEntry): PersistedPrStatusEntry => ({
status: entry.status,
isInitialStatusResolved: entry.isInitialStatusResolved,
lastRefreshAt: entry.lastRefreshAt,
lastDiscoveryPollAt: entry.lastDiscoveryPollAt,
identity: getIdentityFromEntry(entry),
resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null,
});
const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry => ({
...createEntry(),
status: entry?.status ?? null,
isInitialStatusResolved: entry?.isInitialStatusResolved ?? false,
lastRefreshAt: entry?.lastRefreshAt ?? 0,
lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0,
identity: entry?.identity ?? null,
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
});
export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
persist(
(set, get) => ({
entries: {},
activeRequestCount: 0,
totalRequestCount: 0,
ensureEntry: (key) => {
set((state) => {
if (state.entries[key]) {
return state;
}
return {
entries: {
...state.entries,
[key]: createEntry(),
},
};
});
},
setParams: (key, params) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: mergeParams(current, params),
},
};
});
},
startWatching: (key) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: {
...current,
watchers: current.watchers + 1,
},
},
};
});
if (timers.has(key)) {
return;
}
const runBootstrapRefresh = (delayMs: number) => {
const timerId = window.setTimeout(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
const entry = get().entries[key];
if (!entry || entry.watchers <= 0) {
return;
}
if (entry.status?.pr) {
return;
}
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
}, delayMs);
const existing = bootstrapTimers.get(key) ?? [];
existing.push(timerId);
bootstrapTimers.set(key, existing);
};
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay));
const timerId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
const entry = get().entries[key];
if (!entry || entry.watchers <= 0) {
return;
}
const hasPr = Boolean(entry.status?.pr);
if (!hasPr) {
const now = Date.now();
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
return;
}
set((state) => {
const current = state.entries[key];
if (!current) {
return state;
}
return {
entries: {
...state.entries,
[key]: {
...current,
lastDiscoveryPollAt: now,
},
},
};
});
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
return;
}
if (isTerminalPrState(entry.status?.pr?.state)) {
return;
}
const elapsed = Date.now() - entry.lastRefreshAt;
const nextInterval = isPendingChecks(entry.status)
? PR_OPEN_BUSY_INTERVAL_MS
: (entry.status?.checks && entry.status.checks.state !== 'pending'
? PR_OPEN_STABLE_INTERVAL_MS
: PR_OPEN_DEFAULT_INTERVAL_MS);
if (elapsed < nextInterval) {
return;
}
void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true });
}, PR_REVALIDATE_INTERVAL_MS);
timers.set(key, timerId);
},
stopWatching: (key) => {
set((state) => {
const current = state.entries[key];
if (!current) {
return state;
}
const watchers = Math.max(0, current.watchers - 1);
return {
entries: {
...state.entries,
[key]: {
...current,
watchers,
},
},
};
});
const entry = get().entries[key];
if (entry && entry.watchers > 0) {
return;
}
const timerId = timers.get(key);
if (typeof timerId === 'number') {
window.clearInterval(timerId);
}
timers.delete(key);
const pendingBootstrapTimers = bootstrapTimers.get(key);
if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) {
pendingBootstrapTimers.forEach((id) => {
window.clearTimeout(id);
});
}
bootstrapTimers.delete(key);
},
refresh: async (key, options) => {
const state = get();
const entry = state.entries[key];
const signature = getSignatureFromEntry(entry);
if (!entry || !signature) {
return;
}
const signatureKeys = getKeysBySignature(state.entries, signature);
const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr));
if (options?.onlyExistingPr && !hasExistingPr) {
return;
}
const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0;
if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) {
return;
}
if (inFlightBySignature.has(signature)) {
return;
}
const params = pickFetchParamsForSignature(state.entries, signature, key);
if (!params) {
return;
}
inFlightBySignature.add(signature);
lastRefreshBySignature.set(signature, Date.now());
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
lastRefreshAt: Date.now(),
isLoading: options?.silent ? current.isLoading : true,
error: null,
};
});
return {
entries: nextEntries,
};
});
if (params.githubAuthChecked && params.githubConnected === false) {
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
status: { connected: false },
error: null,
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
inFlightBySignature.delete(signature);
return;
}
if (!params.github?.prStatus) {
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
status: null,
error: 'GitHub runtime API unavailable',
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
inFlightBySignature.delete(signature);
return;
}
try {
set((prev) => ({
...prev,
activeRequestCount: prev.activeRequestCount + 1,
totalRequestCount: prev.totalRequestCount + 1,
}));
const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
const prevPr = current.status?.pr;
const nextPr = next.pr;
const shouldCarryBody = Boolean(
nextPr
&& prevPr
&& nextPr.number === prevPr.number
&& (!nextPr.body || !nextPr.body.trim())
&& typeof prevPr.body === 'string'
&& prevPr.body.trim().length > 0,
);
const status = shouldCarryBody && nextPr && prevPr?.body
? {
...next,
pr: {
...nextPr,
body: prevPr.body,
},
}
: next;
const resolvedRemoteName = status.resolvedRemoteName ?? current.resolvedRemoteName ?? params.remoteName ?? null;
const identity = getIdentityFromEntry(current) ?? {
directory: params.directory,
branch: params.branch,
remoteName: params.remoteName ?? null,
};
nextEntries[signatureKey] = {
...current,
status,
error: null,
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
resolvedRemoteName,
identity: {
...identity,
remoteName: resolvedRemoteName ?? identity.remoteName ?? null,
},
};
});
return {
entries: nextEntries,
};
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
error: message || 'Failed to load PR status',
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
} finally {
inFlightBySignature.delete(signature);
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
}
},
refreshTargets: async (targets, options) => {
const keys = Array.from(new Set(
targets
.map((target) => {
const directory = target.directory.trim();
const branch = target.branch.trim();
if (!directory || !branch) {
return null;
}
return getGitHubPrStatusKey(directory, branch, target.remoteName ?? null);
})
.filter((key): key is string => Boolean(key)),
));
await mapWithConcurrency(keys, PR_STATUS_REFRESH_CONCURRENCY, (key) => get().refresh(key, options));
},
updateStatus: (key, updater) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: {
...current,
status: updater(current.status),
},
},
};
});
},
}),
{
name: PR_STATUS_STORAGE_KEY,
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
entries: Object.fromEntries(
Object.entries(state.entries)
.filter(([, entry]) => {
const identity = getIdentityFromEntry(entry);
if (!identity?.directory || !identity.branch) {
return false;
}
const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt);
return freshness === 0 || Date.now() - freshness < PR_PERSIST_TTL_MS;
})
.map(([key, entry]) => [key, toPersistedEntry(entry)]),
),
}),
merge: (persistedState, currentState) => {
const persistedEntries = (persistedState as { entries?: Record<string, PersistedPrStatusEntry> } | undefined)?.entries ?? {};
const current = currentState as GitHubPrStatusStore;
return {
...current,
entries: Object.fromEntries(
Object.entries(persistedEntries).map(([key, entry]) => [key, hydrateEntry(entry)]),
),
};
},
},
),
);
export const usePrStatusForDirectoryBranch = (directory: string | null, branch: string | null) => {
return useGitHubPrStatusStore((state) => {
if (!directory || !branch) return null;
const key = getGitHubPrStatusKey(directory, branch);
return state.entries[key] ?? null;
});
};
export type PrVisualSummary = {
number: number;
visualState: string;
prState: string;
draft: boolean;
title: string | null;
url: string | null;
base: string | null;
head: string | null;
checks: { state: string; total: number; success: number; failure: number; pending: number } | null;
canMerge: boolean | null;
mergeableState: string | null;
repo: { owner: string; repo: string } | null;
};
const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | null => {
const pr = status?.pr;
if (!pr) return null;
if (pr.state === 'merged') return 'merged';
if (pr.state === 'closed') return 'closed';
if (pr.draft) return 'draft';
const checksFailed = status?.checks?.state === 'failure';
const ms = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || ms === 'blocked' || ms === 'dirty';
if (checksFailed || notMergeable) return 'blocked';
return 'open';
};
const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
const vs = derivePrVisualState(entry.status ?? null);
const pr = entry.status?.pr;
if (!vs || !pr?.number) return null;
return {
number: pr.number,
visualState: vs,
prState: pr.state,
draft: Boolean(pr.draft),
title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null,
url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null,
base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null,
head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null,
checks: entry.status?.checks
? { state: entry.status.checks.state, total: entry.status.checks.total, success: entry.status.checks.success, failure: entry.status.checks.failure, pending: entry.status.checks.pending }
: null,
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null,
repo: entry.status?.repo ? { owner: entry.status.repo.owner, repo: entry.status.repo.repo } : null,
};
};
const summarySignature = (s: PrVisualSummary): string =>
`${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`;
let prKeyedCacheSigs = new Map<string, string>();
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
export const usePrVisualSummaryByKeys = (keys: string[]) => {
return useGitHubPrStatusStore((state) => {
// Derive summaries for requested keys only
const nextSigs = new Map<string, string>();
const nextSummaries = new Map<string, PrVisualSummary>();
for (const key of keys) {
const entry = state.entries[key];
if (!entry) continue;
const summary = deriveSummary(entry);
if (!summary) continue;
const sig = summarySignature(summary);
nextSigs.set(key, sig);
nextSummaries.set(key, summary);
}
// Compare with cached signatures
if (nextSigs.size === prKeyedCacheSigs.size) {
let same = true;
for (const [k, sig] of nextSigs) {
if (prKeyedCacheSigs.get(k) !== sig) { same = false; break; }
}
if (same) return prKeyedCacheResult;
}
prKeyedCacheSigs = nextSigs;
prKeyedCacheResult = nextSummaries;
return nextSummaries;
});
};