diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 3af82a3..882616f 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -11,6 +11,11 @@ export const githubCachePolicy = { staleTimeMs: 2 * 60 * 1000, gcTimeMs: 60 * 60 * 1000, }, + /** User-scoped "My pulls/issues/reviews" aggregates — shorter TTL + faster webhook alignment */ + mine: { + staleTimeMs: 30 * 1000, + gcTimeMs: 15 * 60 * 1000, + }, detail: { staleTimeMs: 30 * 1000, gcTimeMs: 10 * 60 * 1000, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 1251ea9..c287343 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -641,6 +641,19 @@ function toMutationError(action: string, error: unknown): MutationResult { return { ok: false, error: `Failed to ${action}: Unknown error` }; } +/** When search is scoped to one repo, webhook `repoMeta` invalidates this cache; otherwise TTL only (global mine signals would misfire). */ +function revalidationSignalKeysForUserItemSearch(input: { + owner?: string; + repo?: string; +}): string[] { + const owner = input.owner?.trim(); + const repo = input.repo?.trim(); + if (owner && repo) { + return [githubRevalidationSignalKeys.repoMeta({ owner, repo })]; + } + return []; +} + export type PullFromRepoInput = { owner: string; repo: string; @@ -4459,6 +4472,27 @@ function mergeMyIssuesResults(results: MyIssuesResult[]): MyIssuesResult { }; } +/** Full refresh replaces cache so merged/closed items drop off; union only when this fetch missed sources (`partial`). */ +function mergeMyPullsCachedWithFresh( + existing: MyPullsResult, + fresh: MyPullsResult, +): MyPullsResult { + if (fresh.partial) { + return mergeMyPullsResults([existing, fresh]); + } + return fresh; +} + +function mergeMyIssuesCachedWithFresh( + existing: MyIssuesResult, + fresh: MyIssuesResult, +): MyIssuesResult { + if (fresh.partial) { + return mergeMyIssuesResults([existing, fresh]); + } + return fresh; +} + function buildSourceSearchQuery({ itemType, role, @@ -4492,14 +4526,14 @@ async function getMyPullsResult({ userId: context.session.user.id, resource: "pulls.mine.graphql.v2", params: { username }, - freshForMs: githubCachePolicy.list.staleTimeMs, + freshForMs: githubCachePolicy.mine.staleTimeMs, signalKeys: [ githubRevalidationSignalKeys.pullsMine, githubRevalidationSignalKeys.installationAccess, ], namespaceKeys: [githubRevalidationSignalKeys.pullsMine], cacheMode: "split", - merge: (existing, fresh) => mergeMyPullsResults([existing, fresh]), + merge: mergeMyPullsCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; const sources = await getMySearchSources(context, username, deadlineAt); @@ -4657,6 +4691,9 @@ async function getMyPullsResult({ if (forbiddenOrgs.length > 0) { data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; } + if (results.length < sources.length) { + data.partial = true; + } if (timedOut || hasRecentGitHubTimeouts()) { data.timedOut = true; } @@ -4683,14 +4720,14 @@ async function getMyIssuesResult({ userId: context.session.user.id, resource: "issues.mine.graphql.v2", params: { username }, - freshForMs: githubCachePolicy.list.staleTimeMs, + freshForMs: githubCachePolicy.mine.staleTimeMs, signalKeys: [ githubRevalidationSignalKeys.issuesMine, githubRevalidationSignalKeys.installationAccess, ], namespaceKeys: [githubRevalidationSignalKeys.issuesMine], cacheMode: "split", - merge: (existing, fresh) => mergeMyIssuesResults([existing, fresh]), + merge: mergeMyIssuesCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; const sources = await getMySearchSources(context, username, deadlineAt); @@ -4823,6 +4860,9 @@ async function getMyIssuesResult({ if (forbiddenOrgs.length > 0) { data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; } + if (results.length < sources.length) { + data.partial = true; + } if (timedOut || hasRecentGitHubTimeouts()) { data.timedOut = true; } @@ -5257,7 +5297,10 @@ export const getPullsFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.pullsMine], + signalKeys: revalidationSignalKeysForUserItemSearch({ + owner: data.owner, + repo: data.repo, + }), request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -5302,7 +5345,12 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.pullsMine], + signalKeys: [ + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ], request: (headers) => context.octokit.rest.pulls.list({ owner: data.owner, @@ -5415,7 +5463,10 @@ export const getIssuesFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.issuesMine], + signalKeys: revalidationSignalKeysForUserItemSearch({ + owner: data.owner, + repo: data.repo, + }), request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -5462,7 +5513,12 @@ export const getIssuesFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.issuesMine], + signalKeys: [ + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ], request: (headers) => context.octokit.rest.issues.listForRepo({ owner: data.owner, diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index e6cba7c..0500589 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -277,8 +277,8 @@ export function githubMyPullsQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.pulls.mine(scope), queryFn: () => getMyPulls(), - staleTime: githubCachePolicy.list.staleTimeMs, - gcTime: githubCachePolicy.list.gcTimeMs, + staleTime: githubCachePolicy.mine.staleTimeMs, + gcTime: githubCachePolicy.mine.gcTimeMs, meta: persistedMeta, }); } @@ -457,8 +457,8 @@ export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.issues.mine(scope), queryFn: () => getMyIssues(), - staleTime: githubCachePolicy.list.staleTimeMs, - gcTime: githubCachePolicy.list.gcTimeMs, + staleTime: githubCachePolicy.mine.staleTimeMs, + gcTime: githubCachePolicy.mine.gcTimeMs, meta: persistedMeta, }); } diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 0fc3d17..118af65 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -123,6 +123,7 @@ export type MyPullsResult = { mentioned: PullSummary[]; involved: PullSummary[]; forbiddenOrgs?: string[]; + partial?: boolean; timedOut?: boolean; }; @@ -131,6 +132,7 @@ export type MyIssuesResult = { authored: IssueSummary[]; mentioned: IssueSummary[]; forbiddenOrgs?: string[]; + partial?: boolean; timedOut?: boolean; }; diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 7732b6a..3ea189f 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -25,7 +25,8 @@ function isSignalMessage(data: unknown): data is SignalMessage { } const RECONNECT_DELAY_MS = 3_000; -const POLL_INTERVAL_MS = 5 * 60 * 1_000; +/** Fallback when WebSocket misses — keep "My" lists reasonably fresh */ +const POLL_INTERVAL_MS = 90 * 1_000; function getWebSocketUrl() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -273,7 +274,7 @@ function useGitHubSignalPoll( pollTimer = setTimeout(pollSignals, POLL_INTERVAL_MS); } - // Seed timestamps immediately, then poll every 5 minutes + // Seed timestamps immediately, then poll on POLL_INTERVAL_MS void pollSignals(); return () => {