Files
kandev/apps/web/hooks/domains/github/pr-commits-resource.ts
T
2026-09-03 19:54:29 +00:00

395 lines
11 KiB
TypeScript

import type { PRCommitInfo } from "@/lib/types/github";
import { getWebSocketClient } from "@/lib/ws/connection";
import { t } from "@/lib/i18n";
export type PRCommitsState = {
commits: PRCommitInfo[];
authoritativeCommits: PRCommitInfo[];
providerHead: string | null;
providerCommitsComplete: boolean;
loading: boolean;
error: string | null;
};
export type PRCommitsRequest = {
workspaceId: string;
owner: string;
repo: string;
prNumber: number;
sourceKey: string;
};
type PRCommitsResponse = {
commits?: PRCommitInfo[];
head_sha?: string;
complete?: boolean;
};
type PRCommitsRequester = (request: PRCommitsRequest) => Promise<PRCommitsResponse | null>;
type ResourceOptions = {
retryDelayMs?: number;
maxEntries?: number;
};
type RetryWait = {
timer: ReturnType<typeof setTimeout>;
resolve: () => void;
};
type ResourceEntry = {
key: string;
identity: string;
request: PRCommitsRequest | null;
snapshot: PRCommitsState;
successfulCommits: PRCommitInfo[];
loaded: boolean;
promise: Promise<PRCommitsState> | null;
listeners: Set<() => void>;
orphaned: boolean;
retryWait: RetryWait | null;
lastUsed: number;
};
const DEFAULT_RETRY_DELAY_MS = 100;
const DEFAULT_MAX_ENTRIES = 24;
const EMPTY_STATE: PRCommitsState = {
commits: [],
authoritativeCommits: [],
providerHead: null,
providerCommitsComplete: false,
loading: false,
error: null,
};
const PENDING_STATE: PRCommitsState = {
...EMPTY_STATE,
loading: true,
};
function pendingState(commits: PRCommitInfo[] = []): PRCommitsState {
return { ...PENDING_STATE, commits };
}
function identityFor(request: PRCommitsRequest): string {
return [request.workspaceId, request.owner, request.repo, request.prNumber].join("\0");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : t("github:failedToFetchPrCommits");
}
async function requestPRCommits(request: PRCommitsRequest): Promise<PRCommitsResponse | null> {
const client = getWebSocketClient();
if (!client) return null;
return client.request<PRCommitsResponse>("github.pr_commits.get", {
workspace_id: request.workspaceId,
owner: request.owner,
repo: request.repo,
number: request.prNumber,
});
}
function successState(response: PRCommitsResponse | null): PRCommitsState {
const commits = response?.commits ?? [];
return {
commits,
authoritativeCommits: commits,
providerHead: response?.head_sha ?? null,
providerCommitsComplete: response?.complete === true,
loading: false,
error: null,
};
}
function failedState(error: unknown, commits: PRCommitInfo[] = []): PRCommitsState {
return {
...EMPTY_STATE,
commits,
error: errorMessage(error),
};
}
function createEntry(
request: PRCommitsRequest | null,
key: string,
successfulCommits: PRCommitInfo[] = [],
): ResourceEntry {
return {
key,
identity: request ? identityFor(request) : key,
request,
snapshot: pendingState(successfulCommits),
successfulCommits,
loaded: false,
promise: null,
listeners: new Set(),
orphaned: false,
retryWait: null,
lastUsed: 0,
};
}
/**
* Creates a small external resource for provider commit history. The factory
* keeps the request policy testable while the hook below owns one app-wide
* instance.
*/
type PRCommitsResourceStore = {
entries: Map<string, ResourceEntry>;
currentKeyByIdentity: Map<string, string>;
retryDelayMs: number;
maxEntries: number;
usageCounter: number;
};
function touch(store: PRCommitsResourceStore, entry: ResourceEntry): void {
entry.lastUsed = ++store.usageCounter;
}
function deleteEntry(store: PRCommitsResourceStore, entry: ResourceEntry): void {
store.entries.delete(entry.key);
if (store.currentKeyByIdentity.get(entry.identity) === entry.key) {
store.currentKeyByIdentity.delete(entry.identity);
}
}
function entryFor(
store: PRCommitsResourceStore,
key: string,
request: PRCommitsRequest | null = null,
): ResourceEntry {
const existing = store.entries.get(key);
if (existing) {
if (request) {
existing.request = request;
existing.identity = identityFor(request);
store.currentKeyByIdentity.set(existing.identity, key);
}
touch(store, existing);
return existing;
}
const identity = request ? identityFor(request) : key;
const retainedKey = store.currentKeyByIdentity.get(identity);
const retainedEntry = retainedKey ? store.entries.get(retainedKey) : undefined;
const entry = createEntry(request, key, retainedEntry?.successfulCommits ?? []);
store.entries.set(key, entry);
if (request) store.currentKeyByIdentity.set(identity, key);
touch(store, entry);
return entry;
}
function prune(store: PRCommitsResourceStore, entry: ResourceEntry): void {
if (store.currentKeyByIdentity.get(entry.identity) === entry.key) {
for (const candidate of store.entries.values()) {
if (
candidate !== entry &&
candidate.identity === entry.identity &&
!candidate.promise &&
candidate.listeners.size === 0
) {
deleteEntry(store, candidate);
}
}
}
const candidates = [...store.entries.values()]
.filter(
(candidate) => candidate !== entry && !candidate.promise && candidate.listeners.size === 0,
)
.sort((left, right) => left.lastUsed - right.lastUsed);
for (const candidate of candidates) {
if (store.entries.size <= store.maxEntries) break;
deleteEntry(store, candidate);
}
}
function clearRetryWait(entry: ResourceEntry): void {
if (!entry.retryWait) return;
clearTimeout(entry.retryWait.timer);
entry.retryWait.resolve();
entry.retryWait = null;
}
function waitBeforeRetry(store: PRCommitsResourceStore, entry: ResourceEntry): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
entry.retryWait = null;
resolve();
}, store.retryDelayMs);
entry.retryWait = { timer, resolve };
});
}
function notify(entry: ResourceEntry): void {
for (const listener of entry.listeners) listener();
}
function isCurrent(store: PRCommitsResourceStore, entry: ResourceEntry): boolean {
return store.entries.get(entry.key) === entry;
}
function publish(
store: PRCommitsResourceStore,
entry: ResourceEntry,
snapshot: PRCommitsState,
loaded: boolean,
): void {
if (!isCurrent(store, entry)) return;
entry.snapshot = snapshot;
if (loaded && !snapshot.error) entry.successfulCommits = snapshot.authoritativeCommits;
entry.loaded = loaded;
touch(store, entry);
notify(entry);
}
function publishFailure(
store: PRCommitsResourceStore,
entry: ResourceEntry,
error: unknown,
): PRCommitsState {
const snapshot = failedState(error, entry.successfulCommits);
publish(store, entry, snapshot, false);
if (
isCurrent(store, entry) &&
entry.listeners.size === 0 &&
(store.currentKeyByIdentity.get(entry.identity) !== entry.key ||
entry.successfulCommits.length === 0)
) {
deleteEntry(store, entry);
}
return snapshot;
}
async function attempt(
requester: PRCommitsRequester,
request: PRCommitsRequest,
): Promise<
{ response: PRCommitsResponse | null; error: null } | { response: null; error: unknown }
> {
try {
const response = await requester(request);
if (response === null) {
return { response: null, error: new Error(t("github:failedToFetchPrCommits")) };
}
return { response, error: null };
} catch (error) {
return { response: null, error };
}
}
async function run(
store: PRCommitsResourceStore,
requester: PRCommitsRequester,
entry: ResourceEntry,
request: PRCommitsRequest,
): Promise<PRCommitsState> {
const first = await attempt(requester, request);
if (!first.error) {
const snapshot = successState(first.response);
publish(store, entry, snapshot, true);
prune(store, entry);
return snapshot;
}
if (isCurrent(store, entry) && !entry.orphaned) await waitBeforeRetry(store, entry);
if (!isCurrent(store, entry) || entry.orphaned) {
return failedState(first.error, entry.successfulCommits);
}
const second = await attempt(requester, request);
if (!second.error) {
const snapshot = successState(second.response);
publish(store, entry, snapshot, true);
prune(store, entry);
return snapshot;
}
return publishFailure(store, entry, second.error);
}
function load(
store: PRCommitsResourceStore,
requester: PRCommitsRequester,
request: PRCommitsRequest,
force = false,
): Promise<PRCommitsState> {
const entry = entryFor(store, request.sourceKey, request);
entry.orphaned = false;
prune(store, entry);
if (entry.promise) return entry.promise;
if (!force && entry.loaded && !entry.snapshot.error) return Promise.resolve(entry.snapshot);
entry.loaded = false;
entry.snapshot = pendingState(entry.successfulCommits);
notify(entry);
const promise = run(store, requester, entry, request);
entry.promise = promise;
void promise.then(
() => {
if (entry.promise === promise) entry.promise = null;
},
() => {
if (entry.promise === promise) entry.promise = null;
},
);
return promise;
}
function subscribe(
store: PRCommitsResourceStore,
request: PRCommitsRequest | null,
listener: () => void,
): () => void {
if (!request) return () => undefined;
const entry = entryFor(store, request.sourceKey, request);
entry.listeners.add(listener);
entry.orphaned = false;
touch(store, entry);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size > 0) return;
entry.orphaned = Boolean(entry.promise);
clearRetryWait(entry);
if (
!entry.promise &&
entry.snapshot.error &&
(store.currentKeyByIdentity.get(entry.identity) !== entry.key ||
entry.successfulCommits.length === 0)
) {
deleteEntry(store, entry);
}
};
}
function getSnapshot(
store: PRCommitsResourceStore,
request: PRCommitsRequest | null,
): PRCommitsState {
if (!request) return EMPTY_STATE;
const entry = entryFor(store, request.sourceKey, request);
// useSyncExternalStore reads during render; touching here keeps actively
// rendered entries recent for bounded LRU eviction.
touch(store, entry);
return entry.snapshot;
}
export function createPRCommitsResource(
requester: PRCommitsRequester = requestPRCommits,
options: ResourceOptions = {},
) {
const store: PRCommitsResourceStore = {
entries: new Map(),
currentKeyByIdentity: new Map(),
retryDelayMs: Math.max(0, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS),
maxEntries: Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES),
usageCounter: 0,
};
return {
getSnapshot: (request: PRCommitsRequest | null) => getSnapshot(store, request),
load: (request: PRCommitsRequest, force = false) => load(store, requester, request, force),
subscribe: (request: PRCommitsRequest | null, listener: () => void) =>
subscribe(store, request, listener),
};
}
export const prCommitsResource = createPRCommitsResource();