From 22181103963a7ef39ae70fee559d400b2061c542 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 25 Apr 2026 11:30:25 -0400 Subject: [PATCH 1/3] add local-first github sync catch-up --- .../src/components/inbox/inbox-page.tsx | 22 +- .../pulls/detail/pull-detail-activity.tsx | 77 +- .../pulls/detail/pull-detail-page.tsx | 4 + apps/dashboard/src/lib/github-cache.ts | 285 ++++++-- apps/dashboard/src/lib/github-revalidation.ts | 19 +- apps/dashboard/src/lib/github.functions.ts | 657 +++++++++++------- apps/dashboard/src/lib/github.types.ts | 13 + .../src/lib/use-github-signal-stream.test.ts | 46 ++ .../src/lib/use-github-signal-stream.ts | 88 ++- 9 files changed, 898 insertions(+), 313 deletions(-) create mode 100644 apps/dashboard/src/lib/use-github-signal-stream.test.ts diff --git a/apps/dashboard/src/components/inbox/inbox-page.tsx b/apps/dashboard/src/components/inbox/inbox-page.tsx index 86124a5..3396c13 100644 --- a/apps/dashboard/src/components/inbox/inbox-page.tsx +++ b/apps/dashboard/src/components/inbox/inbox-page.tsx @@ -45,6 +45,8 @@ import type { NotificationParticipant, NotificationsResult, } from "#/lib/github.types"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; const routeApi = getRouteApi("/_protected/inbox"); @@ -120,11 +122,25 @@ export function InboxPage() { const [selectedId, setSelectedId] = useState(null); const [filter, setFilter] = useState("unread"); - const queryInput = { all: filter === "all" }; + const queryInput = useMemo(() => ({ all: filter === "all" }), [filter]); + const queryKey = useMemo( + () => githubQueryKeys.notifications.list(scope, queryInput), + [scope, queryInput], + ); + const webhookRefreshTargets = useMemo( + () => [ + { + queryKey, + signalKeys: [githubRevalidationSignalKeys.notifications], + }, + ], + [queryKey], + ); const query = useQuery({ ...githubNotificationsQueryOptions(scope, queryInput), enabled: hasMounted, }); + useGitHubSignalStream(webhookRefreshTargets); const queryClient = useQueryClient(); const notifications = query.data?.notifications ?? []; @@ -208,6 +224,7 @@ const InboxSidebar = memo(function InboxSidebar({ const prev = queryClient.getQueryData(queryKey); if (prev) { queryClient.setQueryData(queryKey, { + ...prev, notifications: prev.notifications.map((n) => ({ ...n, unread: false, @@ -234,6 +251,7 @@ const InboxSidebar = memo(function InboxSidebar({ const prev = queryClient.getQueryData(queryKey); if (prev) { queryClient.setQueryData(queryKey, { + ...prev, notifications: prev.notifications.filter((n) => n.unread), }); } @@ -436,6 +454,7 @@ const InboxRow = memo(function InboxRow({ const prev = queryClient.getQueryData(queryKey); if (prev) { queryClient.setQueryData(queryKey, { + ...prev, notifications: prev.notifications.filter( (n) => n.id !== notification.id, ), @@ -455,6 +474,7 @@ const InboxRow = memo(function InboxRow({ const prev = queryClient.getQueryData(queryKey); if (prev) { queryClient.setQueryData(queryKey, { + ...prev, notifications: prev.notifications.map((n) => n.id === notification.id ? { ...n, unread: false } : n, ), diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index 4988af9..a87d16f 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -51,7 +51,6 @@ import { lazy, Suspense, useCallback, - useEffect, useMemo, useRef, useState, @@ -113,10 +112,12 @@ import type { PullWorkflowApproval, TimelineEvent, } from "#/lib/github.types"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { mergeIssueStateIntoCloseEvent, parseCloseReason, } from "#/lib/timeline-close-reason"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { usePrefersNoHover } from "#/lib/use-prefers-no-hover"; import { checkPermissionWarning } from "#/lib/warning-store"; @@ -334,9 +335,45 @@ function MergeStatusSection({ prTitle: string; firstCommitMessage?: string; }) { + const input = useMemo( + () => ({ owner, repo, pullNumber }), + [owner, repo, pullNumber], + ); + const statusQueryKey = useMemo( + () => githubQueryKeys.pulls.status(scope, input), + [scope, input], + ); const statusQuery = useQuery({ - ...githubPullStatusQueryOptions(scope, { owner, repo, pullNumber }), + ...githubPullStatusQueryOptions(scope, input), }); + const workflowStatusRefreshTargets = useMemo( + () => [ + { + queryKey: statusQueryKey, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity(input), + githubRevalidationSignalKeys.repoProtection({ + owner: input.owner, + repo: input.repo, + }), + githubRevalidationSignalKeys.repoStatuses({ + owner: input.owner, + repo: input.repo, + }), + ...(statusQuery.data?.pendingWorkflowApprovals ?? []).map( + (approval) => + githubRevalidationSignalKeys.workflowRunEntity({ + owner: input.owner, + repo: input.repo, + runId: approval.workflowRunId, + }), + ), + ], + }, + ], + [input, statusQuery.data?.pendingWorkflowApprovals, statusQueryKey], + ); + useGitHubSignalStream(workflowStatusRefreshTargets); const status = statusQuery.data ?? null; @@ -344,6 +381,7 @@ function MergeStatusSection({ return ( 0 || pendingWorkflowApprovals.length > 0) && ( githubQueryKeys.pulls.status(scope, { owner, repo, pullNumber }), + [scope, owner, repo, pullNumber], + ); const pendingTotal = checks.pending + checks.expected; const approvalCount = pendingWorkflowApprovals.length; @@ -894,33 +941,27 @@ function ChecksSection({ toast.warning( `Approved ${approved} workflow${approved !== 1 ? "s" : ""}, but ${failed} failed`, ); + } else { + toast.success( + `Approved ${pendingWorkflowApprovals.length} workflow${pendingWorkflowApprovals.length !== 1 ? "s" : ""}`, + ); } - // Keep the button in loading state; the effect below resets it once the - // workflow_run webhook invalidates the cache and the pending list drains. - await queryClient.invalidateQueries({ queryKey: ["github"] }); + await queryClient.invalidateQueries({ + queryKey: statusQueryKey, + exact: true, + refetchType: "active", + }); } else { toast.error(result.error); checkPermissionWarning(result, `${owner}/${repo}`); - setIsApproving(false); } } catch { toast.error("Failed to approve workflows"); + } finally { setIsApproving(false); } }; - // Reset the approving state when the pending list drains (webhook arrived) or - // after a safety timeout to avoid a permanently-stuck spinner. - useEffect(() => { - if (!isApproving) return; - if (pendingWorkflowApprovals.length === 0) { - setIsApproving(false); - return; - } - const timer = setTimeout(() => setIsApproving(false), 30_000); - return () => clearTimeout(timer); - }, [isApproving, pendingWorkflowApprovals.length]); - return ( diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx index d84cee6..3250807 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -76,6 +76,10 @@ export function PullDetailContent({ queryKey: githubQueryKeys.pulls.status(scope, input), signalKeys: [ githubRevalidationSignalKeys.pullEntity(input), + githubRevalidationSignalKeys.repoProtection({ + owner: input.owner, + repo: input.repo, + }), githubRevalidationSignalKeys.repoStatuses({ owner: input.owner, repo: input.repo, diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts index af57ce4..8300e1c 100644 --- a/apps/dashboard/src/lib/github-cache.ts +++ b/apps/dashboard/src/lib/github-cache.ts @@ -52,6 +52,16 @@ export type GitHubFetchResult = metadata: GitHubResponseMetadata; }; +export type GitHubLocalFirstMeta = { + cacheStatus: "fresh" | "stale" | "miss"; + fetchedAt: number | null; + isRevalidating: boolean; +}; + +export type BackgroundExecutionContext = { + waitUntil(promise: Promise): void; +}; + type GetOrRevalidateGitHubResourceOptions = { userId: string; resource: string; @@ -618,6 +628,208 @@ async function persistGitHubCacheEntry({ await Promise.all(writes); } +async function resolveGitHubCacheEntry({ + userId, + resource, + params, + signalKeys, + namespaceKeys, + cacheMode, + payloadRetentionSeconds, + store, + payloadStore, + getLatestSignalUpdatedAt, + getNamespaceVersions, + now, +}: { + userId: string; + resource: string; + params: unknown; + signalKeys: string[]; + namespaceKeys: string[]; + cacheMode: "legacy" | "split"; + payloadRetentionSeconds: number; + store?: GitHubCacheStore; + payloadStore?: GitHubPayloadCacheStore | null; + getLatestSignalUpdatedAt: (signalKeys: string[]) => Promise; + getNamespaceVersions: ( + namespaceKeys: string[], + ) => Promise>; + now: () => number; +}) { + let resolvedStore = store ?? null; + const resolvedPayloadStore = + typeof payloadStore !== "undefined" + ? payloadStore + : cacheMode === "split" + ? await getGitHubPayloadCacheStore() + : null; + const paramsJson = stableSerialize(params); + const cacheKey = buildGitHubCacheKey({ userId, resource, paramsJson }); + const getResolvedStore = async () => { + if (!resolvedStore) { + resolvedStore = await getGitHubCacheStore(); + } + + return resolvedStore; + }; + const uniqueNamespaceKeys = Array.from(new Set(namespaceKeys)); + const namespaceVersions = + cacheMode === "split" + ? await getNamespaceVersions(uniqueNamespaceKeys) + : {}; + const payloadStorageKey = + cacheMode === "split" && resolvedPayloadStore + ? await buildGitHubPayloadStorageKey({ + userId, + resource, + paramsJson, + namespaceKeys: uniqueNamespaceKeys, + namespaceVersions, + }) + : null; + const splitEntry = + resolvedPayloadStore && payloadStorageKey + ? await resolvedPayloadStore.get(payloadStorageKey) + : null; + const legacyEntry = splitEntry + ? null + : await (await getResolvedStore()).get(cacheKey); + const existingEntry = splitEntry ?? legacyEntry; + const currentTime = now(); + const latestSignalUpdatedAt = + signalKeys.length > 0 ? await getLatestSignalUpdatedAt(signalKeys) : null; + const isSignalNewerThanCache = Boolean( + existingEntry && + typeof latestSignalUpdatedAt === "number" && + latestSignalUpdatedAt > existingEntry.fetchedAt, + ); + + if (legacyEntry && resolvedPayloadStore && payloadStorageKey) { + await resolvedPayloadStore.put( + payloadStorageKey, + legacyEntry, + payloadRetentionSeconds, + ); + } + + return { + cacheKey, + currentTime, + existingEntry, + getResolvedStore, + paramsJson, + payloadStorageKey, + resolvedPayloadStore, + isSignalNewerThanCache, + }; +} + +export async function getGitHubResourceLocalFirst({ + executionContext, + onBackgroundRefreshSettled, + ...options +}: GetOrRevalidateGitHubResourceOptions & { + executionContext?: BackgroundExecutionContext | null; + onBackgroundRefreshSettled?: () => Promise | void; +}): Promise<{ data: TData; meta: GitHubLocalFirstMeta }> { + const { + userId, + resource, + params, + signalKeys = [], + namespaceKeys = [], + cacheMode = "legacy", + payloadRetentionSeconds = DEFAULT_GITHUB_PAYLOAD_RETENTION_SECONDS, + now = Date.now, + store, + payloadStore, + getLatestSignalUpdatedAt = getLatestGitHubRevalidationSignalUpdatedAt, + getNamespaceVersions = getGitHubCacheNamespaceVersions, + } = options; + const resolved = await resolveGitHubCacheEntry({ + userId, + resource, + params, + signalKeys, + namespaceKeys, + cacheMode, + payloadRetentionSeconds, + store, + payloadStore, + getLatestSignalUpdatedAt, + getNamespaceVersions, + now, + }); + + if ( + resolved.existingEntry && + resolved.existingEntry.freshUntil > resolved.currentTime && + !resolved.isSignalNewerThanCache + ) { + return { + data: parseCachedPayload(resolved.existingEntry.payloadJson), + meta: { + cacheStatus: "fresh", + fetchedAt: resolved.existingEntry.fetchedAt, + isRevalidating: false, + }, + }; + } + + if (resolved.existingEntry) { + const previousFetchedAt = resolved.existingEntry.fetchedAt; + const refreshPromise = getOrRevalidateGitHubResource({ + ...options, + store: store ?? (await resolved.getResolvedStore()), + payloadStore: resolved.resolvedPayloadStore, + now, + getLatestSignalUpdatedAt, + getNamespaceVersions, + }) + .then(async () => { + const nextEntry = await (await resolved.getResolvedStore()).get( + resolved.cacheKey, + ); + if ( + nextEntry && + nextEntry.fetchedAt > previousFetchedAt && + onBackgroundRefreshSettled + ) { + await onBackgroundRefreshSettled(); + } + }) + .catch(() => { + // Best effort: stale cached payload already served to the caller. + }); + + if (executionContext?.waitUntil) { + executionContext.waitUntil(refreshPromise); + } else { + void refreshPromise; + } + + return { + data: parseCachedPayload(resolved.existingEntry.payloadJson), + meta: { + cacheStatus: "stale", + fetchedAt: resolved.existingEntry.fetchedAt, + isRevalidating: true, + }, + }; + } + + const data = await getOrRevalidateGitHubResource(options); + return { + data, + meta: { + cacheStatus: "miss", + fetchedAt: null, + isRevalidating: false, + }, + }; +} + export async function getOrRevalidateGitHubResource({ userId, resource, @@ -654,58 +866,31 @@ export async function getOrRevalidateGitHubResource({ } const task = (async () => { - const getResolvedStore = async () => { - if (!resolvedStore) { - resolvedStore = await getGitHubCacheStore(); - } - - return resolvedStore; - }; - const uniqueNamespaceKeys = Array.from(new Set(namespaceKeys)); - const namespaceVersions = - cacheMode === "split" - ? await getNamespaceVersions(uniqueNamespaceKeys) - : {}; - const payloadStorageKey = - cacheMode === "split" && resolvedPayloadStore - ? await buildGitHubPayloadStorageKey({ - userId, - resource, - paramsJson, - namespaceKeys: uniqueNamespaceKeys, - namespaceVersions, - }) - : null; - const splitEntry = - resolvedPayloadStore && payloadStorageKey - ? await resolvedPayloadStore.get(payloadStorageKey) - : null; - const legacyEntry = splitEntry - ? null - : await (await getResolvedStore()).get(cacheKey); - const existingEntry = splitEntry ?? legacyEntry; - const currentTime = now(); - const latestSignalUpdatedAt = - signalKeys.length > 0 ? await getLatestSignalUpdatedAt(signalKeys) : null; - const isSignalNewerThanCache = Boolean( - existingEntry && - typeof latestSignalUpdatedAt === "number" && - latestSignalUpdatedAt > existingEntry.fetchedAt, - ); + const resolved = await resolveGitHubCacheEntry({ + userId, + resource, + params, + signalKeys, + namespaceKeys, + cacheMode, + payloadRetentionSeconds, + store: resolvedStore ?? undefined, + payloadStore: resolvedPayloadStore, + getLatestSignalUpdatedAt, + getNamespaceVersions, + now, + }); + resolvedStore = await resolved.getResolvedStore(); + const existingEntry = resolved.existingEntry; + const currentTime = resolved.currentTime; + const payloadStorageKey = resolved.payloadStorageKey; + const isSignalNewerThanCache = resolved.isSignalNewerThanCache; if ( existingEntry && existingEntry.freshUntil > currentTime && !isSignalNewerThanCache ) { - if (legacyEntry && resolvedPayloadStore && payloadStorageKey) { - await resolvedPayloadStore.put( - payloadStorageKey, - legacyEntry, - payloadRetentionSeconds, - ); - } - return parseCachedPayload(existingEntry.payloadJson); } @@ -725,7 +910,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: staleEntry, - legacyStore: await getResolvedStore(), + legacyStore: resolvedStore, payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -743,7 +928,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: staleEntry, - legacyStore: await getResolvedStore(), + legacyStore: resolvedStore, payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -778,7 +963,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: refreshedEntry, - legacyStore: await getResolvedStore(), + legacyStore: resolvedStore, payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -814,7 +999,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: nextEntry, - legacyStore: await getResolvedStore(), + legacyStore: resolvedStore, payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts index 2c7d054..5b27147 100644 --- a/apps/dashboard/src/lib/github-revalidation.ts +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -3,6 +3,7 @@ import type { Tab } from "./tab-store"; export const githubRevalidationSignalKeys = { pullsMine: "pulls.mine", issuesMine: "issues.mine", + notifications: "notifications", repoMeta: (input: { owner: string; repo: string }) => `repoMeta:${input.owner}/${input.repo}`, repoLabels: (input: { owner: string; repo: string }) => @@ -197,7 +198,10 @@ export function getGitHubWebhookRevalidationSignalKeys( event === "installation_repositories" || event === "github_app_authorization" ) { - return [githubRevalidationSignalKeys.installationAccess]; + return [ + githubRevalidationSignalKeys.installationAccess, + githubRevalidationSignalKeys.notifications, + ]; } const repository = getRepositoryIdentity(payload); @@ -210,6 +214,7 @@ export function getGitHubWebhookRevalidationSignalKeys( return typeof pullNumber === "number" ? [ githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -222,6 +227,7 @@ export function getGitHubWebhookRevalidationSignalKeys( ] : [ githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -254,6 +260,7 @@ export function getGitHubWebhookRevalidationSignalKeys( return issueIdentity.isPullRequest ? [ githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -266,6 +273,7 @@ export function getGitHubWebhookRevalidationSignalKeys( ] : [ githubRevalidationSignalKeys.issuesMine, + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -286,6 +294,7 @@ export function getGitHubWebhookRevalidationSignalKeys( return issueIdentity.isPullRequest ? [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.pullEntity({ owner: repository.owner, repo: repository.repo, @@ -293,6 +302,7 @@ export function getGitHubWebhookRevalidationSignalKeys( }), ] : [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.issueEntity({ owner: repository.owner, repo: repository.repo, @@ -303,6 +313,7 @@ export function getGitHubWebhookRevalidationSignalKeys( if (event === "push") { return [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -317,6 +328,7 @@ export function getGitHubWebhookRevalidationSignalKeys( if (event === "create" || event === "delete") { return [ githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoMeta({ owner: repository.owner, repo: repository.repo, @@ -342,6 +354,7 @@ export function getGitHubWebhookRevalidationSignalKeys( event === "branch_protection_configuration" ) { return [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoProtection({ owner: repository.owner, repo: repository.repo, @@ -354,6 +367,7 @@ export function getGitHubWebhookRevalidationSignalKeys( // this and re-fetches its status — captures CodeRabbit/CircleCI updates. if (event === "status") { return [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.repoStatuses({ owner: repository.owner, repo: repository.repo, @@ -366,6 +380,7 @@ export function getGitHubWebhookRevalidationSignalKeys( const pullSignals = getWorkflowRunPullSignals(payload); return typeof runId === "number" ? [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.actionsRepo({ owner: repository.owner, repo: repository.repo, @@ -378,6 +393,7 @@ export function getGitHubWebhookRevalidationSignalKeys( ...pullSignals, ] : [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.actionsRepo({ owner: repository.owner, repo: repository.repo, @@ -391,6 +407,7 @@ export function getGitHubWebhookRevalidationSignalKeys( const jobId = getWorkflowJobId(payload); return [ + githubRevalidationSignalKeys.notifications, githubRevalidationSignalKeys.actionsRepo({ owner: repository.owner, repo: repository.repo, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 580ed1f..21c26f4 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -16,6 +16,7 @@ import type { GitHubActor, GitHubContributionCalendar, GitHubLabel, + GitHubLocalFirstMeta, GitHubUserProfile, IssueComment, IssueDetail, @@ -84,11 +85,13 @@ import { import { getGitHubAppSlug } from "./github-app.server"; import { shouldReauthorizeGitHubApp } from "./github-auth-errors"; import { + type BackgroundExecutionContext, bumpGitHubCacheNamespaces, bustGitHubCache, createGitHubResponseMetadata, type GitHubConditionalHeaders, type GitHubFetchResult, + getGitHubResourceLocalFirst, getOrRevalidateGitHubResource, markGitHubRevalidationSignals, } from "./github-cache"; @@ -127,6 +130,54 @@ type GitHubRestResponse = { status: number; }; +function getBackgroundExecutionContext( + value: unknown, +): BackgroundExecutionContext | null { + if ( + value && + typeof value === "object" && + "waitUntil" in value && + typeof value.waitUntil === "function" + ) { + return value as BackgroundExecutionContext; + } + + if ( + value && + typeof value === "object" && + "cloudflare" in value && + value.cloudflare && + typeof value.cloudflare === "object" && + "ctx" in value.cloudflare && + value.cloudflare.ctx && + typeof value.cloudflare.ctx === "object" && + "waitUntil" in value.cloudflare.ctx && + typeof value.cloudflare.ctx.waitUntil === "function" + ) { + return value.cloudflare.ctx as BackgroundExecutionContext; + } + + return null; +} + +function withLocalFirstMeta( + data: T, + meta: GitHubLocalFirstMeta, +): T { + return { ...data, __meta: meta }; +} + +async function broadcastLocalFirstSignalKeys(signalKeys: string[]) { + if (signalKeys.length === 0) { + return; + } + + const { broadcastSignalKeys } = await import( + "./signal-relay-broadcast.server" + ); + await broadcastSignalKeys(signalKeys); +} + type SearchItem = Awaited< ReturnType >["data"]["items"][number]; @@ -420,6 +471,11 @@ type ListForUserRepo = Awaited< function mapGithubRestRepoToUserRepoSummary( repo: AuthenticatedUserRepo | ListForUserRepo, ): UserRepoSummary { + const repoId = repo.id; + if (typeof repoId !== "number") { + throw new Error("GitHub repository payload missing id"); + } + const visibility: UserRepoSummary["visibility"] = repo.visibility === "internal" ? "internal" @@ -427,7 +483,7 @@ function mapGithubRestRepoToUserRepoSummary( ? "private" : "public"; return { - id: repo.id!, + id: repoId, name: repo.name ?? "", fullName: repo.full_name ?? "", description: repo.description ?? null, @@ -1981,12 +2037,16 @@ async function getInstallationAccessIndex( } if (installation.repositorySelection === "selected") { + if (!appUserOctokit) { + continue; + } + try { // Use the app-user client (not the OAuth client) — // this endpoint requires a GitHub App user-to-server token. const repos = await listPaginatedGitHubItems({ request: (page) => - appUserOctokit!.rest.apps.listInstallationReposForAuthenticatedUser( + appUserOctokit.rest.apps.listInstallationReposForAuthenticatedUser( { installation_id: installation.id, page, @@ -2599,7 +2659,6 @@ async function getPullRequestBypassState({ Boolean(candidate) && candidates.indexOf(candidate) === index, ); - let rulesetState: boolean | null = null; for (const candidate of contexts) { const candidateRulesetState = await getRulesetPullRequestBypassState({ branch, @@ -2608,18 +2667,16 @@ async function getPullRequestBypassState({ repo, }); if (candidateRulesetState === false) { - rulesetState ??= false; + return false; } if (candidateRulesetState === true) { - rulesetState = true; + return true; } } - let legacyState: boolean | null = null; for (const candidate of contexts) { try { - const viewerResponse = - await candidate.octokit.rest.users.getAuthenticated(); + const viewer = await getViewer(candidate); const candidateLegacyState = await legacyBranchProtectionAllowsPullRequestBypass({ branch, @@ -2627,24 +2684,18 @@ async function getPullRequestBypassState({ owner, permissions, repo, - viewerLogin: viewerResponse.data.login, + viewerLogin: viewer.login, }); if (candidateLegacyState === false) { - legacyState ??= false; + return false; } if (candidateLegacyState === true) { - legacyState = true; + return true; } } catch {} } - if (rulesetState === false || legacyState === false) { - return false; - } - - return ( - rulesetState === true || legacyState === true || permissions?.admin === true - ); + return permissions?.admin === true; } function buildUserSearchQuery({ @@ -4098,6 +4149,7 @@ async function getPullStatusResult( context: GitHubContext, data: PullFromRepoInput, pull?: RepoPullDetail, + executionContext?: BackgroundExecutionContext | null, ): Promise { const pullNamespaceKey = githubRevalidationSignalKeys.pullEntity({ owner: data.owner, @@ -4108,15 +4160,26 @@ async function getPullStatusResult( owner: data.owner, repo: data.repo, }); + const repoProtectionKey = githubRevalidationSignalKeys.repoProtection({ + owner: data.owner, + repo: data.repo, + }); - return getOrRevalidateGitHubResource({ + const result = await getGitHubResourceLocalFirst({ userId: context.session.user.id, resource: "pulls.status.v3", params: data, freshForMs: githubCachePolicy.status.staleTimeMs, - signalKeys: [pullNamespaceKey, repoStatusesKey], + signalKeys: [pullNamespaceKey, repoStatusesKey, repoProtectionKey], namespaceKeys: [pullNamespaceKey], cacheMode: "split", + executionContext, + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([ + pullNamespaceKey, + repoStatusesKey, + repoProtectionKey, + ]), fetcher: async () => { const pullForStatus = pull ?? @@ -4138,11 +4201,14 @@ async function getPullStatusResult( }; }, }); + + return withLocalFirstMeta(result.data, result.meta); } async function getPullPageDataViaGraphQL( context: GitHubContext, data: PullFromRepoInput, + executionContext?: BackgroundExecutionContext | null, ): Promise { const pullNamespaceKey = githubRevalidationSignalKeys.pullEntity({ owner: data.owner, @@ -4150,7 +4216,7 @@ async function getPullPageDataViaGraphQL( pullNumber: data.pullNumber, }); - return getOrRevalidateGitHubResource({ + const result = await getGitHubResourceLocalFirst({ userId: context.session.user.id, resource: "pulls.pageData.graphql.v2", params: data, @@ -4158,6 +4224,9 @@ async function getPullPageDataViaGraphQL( signalKeys: [pullNamespaceKey], namespaceKeys: [pullNamespaceKey], cacheMode: "split", + executionContext, + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([pullNamespaceKey]), fetcher: async () => { const viewerPromise = getGitHubUserContextForRepository({ owner: data.owner, @@ -4331,6 +4400,8 @@ async function getPullPageDataViaGraphQL( }; }, }); + + return withLocalFirstMeta(result.data, result.meta); } async function getPullPageDataViaRest( @@ -4387,9 +4458,10 @@ async function getPullPageDataViaRest( async function getPullPageDataResult( context: GitHubContext, data: PullFromRepoInput, + executionContext?: BackgroundExecutionContext | null, ): Promise { try { - return await getPullPageDataViaGraphQL(context, data); + return await getPullPageDataViaGraphQL(context, data, executionContext); } catch (error) { console.error("[pull-page:gql] failed, falling back to REST", error); return getPullPageDataViaRest(context, data); @@ -4480,6 +4552,7 @@ async function getIssueCommentsResult( async function getIssuePageDataViaGraphQL( context: GitHubContext, data: IssueFromRepoInput, + executionContext?: BackgroundExecutionContext | null, ): Promise { const issueNamespaceKey = githubRevalidationSignalKeys.issueEntity({ owner: data.owner, @@ -4487,7 +4560,7 @@ async function getIssuePageDataViaGraphQL( issueNumber: data.issueNumber, }); - return getOrRevalidateGitHubResource({ + const result = await getGitHubResourceLocalFirst({ userId: context.session.user.id, resource: "issues.pageData.graphql.v2", params: data, @@ -4495,6 +4568,9 @@ async function getIssuePageDataViaGraphQL( signalKeys: [issueNamespaceKey], namespaceKeys: [issueNamespaceKey], cacheMode: "split", + executionContext, + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([issueNamespaceKey]), fetcher: async () => { const viewerPromise = getGitHubUserContextForRepository({ owner: data.owner, @@ -4628,6 +4704,8 @@ async function getIssuePageDataViaGraphQL( }; }, }); + + return withLocalFirstMeta(result.data, result.meta); } async function getIssuePageDataViaRest( @@ -4676,9 +4754,10 @@ async function getIssuePageDataViaRest( async function getIssuePageDataResult( context: GitHubContext, data: IssueFromRepoInput, + executionContext?: BackgroundExecutionContext | null, ): Promise { try { - return await getIssuePageDataViaGraphQL(context, data); + return await getIssuePageDataViaGraphQL(context, data, executionContext); } catch (error) { console.error("[issue-page:gql] failed, falling back to REST", error); return getIssuePageDataViaRest(context, data); @@ -4941,11 +5020,13 @@ function buildSourceSearchQuery({ async function getMyPullsResult({ context, username, + executionContext, }: { context: GitHubContext; username: string; + executionContext?: BackgroundExecutionContext | null; }) { - return getOrRevalidateGitHubResource({ + const result = await getGitHubResourceLocalFirst({ userId: context.session.user.id, resource: "pulls.mine.graphql.v2", params: { username }, @@ -4956,6 +5037,9 @@ async function getMyPullsResult({ ], namespaceKeys: [githubRevalidationSignalKeys.pullsMine], cacheMode: "split", + executionContext, + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([githubRevalidationSignalKeys.pullsMine]), merge: mergeMyPullsCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; @@ -5130,16 +5214,20 @@ async function getMyPullsResult({ }; }, }); + + return withLocalFirstMeta(result.data, result.meta); } async function getMyIssuesResult({ context, username, + executionContext, }: { context: GitHubContext; username: string; + executionContext?: BackgroundExecutionContext | null; }) { - return getOrRevalidateGitHubResource({ + const result = await getGitHubResourceLocalFirst({ userId: context.session.user.id, resource: "issues.mine.graphql.v2", params: { username }, @@ -5150,6 +5238,9 @@ async function getMyIssuesResult({ ], namespaceKeys: [githubRevalidationSignalKeys.issuesMine], cacheMode: "split", + executionContext, + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([githubRevalidationSignalKeys.issuesMine]), merge: mergeMyIssuesCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; @@ -5299,6 +5390,8 @@ async function getMyIssuesResult({ }; }, }); + + return withLocalFirstMeta(result.data, result.meta); } function identityValidator(data: TInput) { @@ -5776,7 +5869,7 @@ function toInstallationTargetType( } export const getMyPulls = createServerFn({ method: "GET" }).handler( - async (): Promise => { + async ({ context: requestContext }): Promise => { const context = await getGitHubContext(); if (!context) { return { @@ -5790,7 +5883,11 @@ export const getMyPulls = createServerFn({ method: "GET" }).handler( const viewer = await getViewer(context); const [result, accessIndex] = await Promise.all([ - getMyPullsResult({ context, username: viewer.login }), + getMyPullsResult({ + context, + username: viewer.login, + executionContext: getBackgroundExecutionContext(requestContext), + }), getInstallationAccessIndex(context), ]); return filterMyPullsResult(result, accessIndex); @@ -5944,7 +6041,7 @@ export const getPullComments = createServerFn({ method: "GET" }) }); export const getMyIssues = createServerFn({ method: "GET" }).handler( - async (): Promise => { + async ({ context: requestContext }): Promise => { const context = await getGitHubContext(); if (!context) { return { @@ -5956,7 +6053,11 @@ export const getMyIssues = createServerFn({ method: "GET" }).handler( const viewer = await getViewer(context); const [result, accessIndex] = await Promise.all([ - getMyIssuesResult({ context, username: viewer.login }), + getMyIssuesResult({ + context, + username: viewer.login, + executionContext: getBackgroundExecutionContext(requestContext), + }), getInstallationAccessIndex(context), ]); return filterMyIssuesResult(result, accessIndex); @@ -6086,36 +6187,58 @@ export const getIssueComments = createServerFn({ method: "GET" }) export const getIssuePageData = createServerFn({ method: "GET" }) .inputValidator(identityValidator) - .handler(async ({ data }): Promise => { - const context = await getGitHubContextForRepository(data); - if (!context) { - return null; - } + .handler( + async ({ + data, + context: requestContext, + }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return null; + } - return getIssuePageDataResult(context, data); - }); + return getIssuePageDataResult( + context, + data, + getBackgroundExecutionContext(requestContext), + ); + }, + ); export const getPullStatus = createServerFn({ method: "GET" }) .inputValidator(identityValidator) - .handler(async ({ data }): Promise => { - const context = await getGitHubContextForRepository(data); - if (!context) { - return null; - } + .handler( + async ({ data, context: requestContext }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return null; + } - return getPullStatusResult(context, data); - }); + return getPullStatusResult( + context, + data, + undefined, + getBackgroundExecutionContext(requestContext), + ); + }, + ); export const getPullPageData = createServerFn({ method: "GET" }) .inputValidator(identityValidator) - .handler(async ({ data }): Promise => { - const context = await getGitHubContextForRepository(data); - if (!context) { - return null; - } + .handler( + async ({ data, context: requestContext }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return null; + } - return getPullPageDataResult(context, data); - }); + return getPullPageDataResult( + context, + data, + getBackgroundExecutionContext(requestContext), + ); + }, + ); type UpdatePullBodyInput = PullFromRepoInput & { body: string }; @@ -8707,27 +8830,31 @@ export const getRepoParticipationStats = createServerFn({ method: "GET" }) export const getRepoOverview = createServerFn({ method: "GET" }) .inputValidator(identityValidator) - .handler(async ({ data }): Promise => { - const context = await getGitHubContextForRepository(data); - if (!context) return null; + .handler( + async ({ data, context: requestContext }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return null; - const repoMetaKey = githubRevalidationSignalKeys.repoMeta(data); - const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); + const repoMetaKey = githubRevalidationSignalKeys.repoMeta(data); + const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); - return getOrRevalidateGitHubResource({ - userId: context.session.user.id, - resource: "repo.overview.v2", - params: data, - freshForMs: githubCachePolicy.repoMeta.staleTimeMs, - signalKeys: [repoMetaKey, repoCodeKey], - namespaceKeys: [repoMetaKey, repoCodeKey], - cacheMode: "split", - fetcher: async () => { - const response = - await executeGitHubGraphQL( - context, - `github repo overview ${data.owner}/${data.repo}`, - `query($owner: String!, $repo: String!) { + const result = await getGitHubResourceLocalFirst({ + userId: context.session.user.id, + resource: "repo.overview.v2", + params: data, + freshForMs: githubCachePolicy.repoMeta.staleTimeMs, + signalKeys: [repoMetaKey, repoCodeKey], + namespaceKeys: [repoMetaKey, repoCodeKey], + cacheMode: "split", + executionContext: getBackgroundExecutionContext(requestContext), + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([repoMetaKey, repoCodeKey]), + fetcher: async () => { + const response = + await executeGitHubGraphQL( + context, + `github repo overview ${data.owner}/${data.repo}`, + `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { databaseId name @@ -8802,29 +8929,33 @@ export const getRepoOverview = createServerFn({ method: "GET" }) resetAt } }`, - { - owner: data.owner, - repo: data.repo, - }, - ); + { + owner: data.owner, + repo: data.repo, + }, + ); - if (!response.repository) { - throw new Error( - `GitHub repository not found: ${data.owner}/${data.repo}`, - ); - } + if (!response.repository) { + throw new Error( + `GitHub repository not found: ${data.owner}/${data.repo}`, + ); + } - const overview = mapGraphQLRepoOverview(response.repository); - overview.viewerHasStarred = await resolveViewerHasStarredForRepo(data); + const overview = mapGraphQLRepoOverview(response.repository); + overview.viewerHasStarred = + await resolveViewerHasStarredForRepo(data); - return { - kind: "success", - data: overview, - metadata: createGraphQLResponseMetadata(response.rateLimit), - }; - }, - }); - }); + return { + kind: "success", + data: overview, + metadata: createGraphQLResponseMetadata(response.rateLimit), + }; + }, + }); + + return withLocalFirstMeta(result.data, result.meta); + }, + ); // --------------------------------------------------------------------------- // Repository discussions (GraphQL-only) @@ -9811,171 +9942,215 @@ type GetNotificationsInput = { export const getNotifications = createServerFn({ method: "GET" }) .inputValidator(identityValidator) - .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); - if (!context) { - return { notifications: [] }; - } + .handler( + async ({ data, context: requestContext }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return { notifications: [] }; + } - const [response, accessIndex] = await Promise.all([ - context.octokit.rest.activity.listNotificationsForAuthenticatedUser({ - all: data.all ?? false, - participating: data.participating ?? false, - per_page: 50, - }), - getInstallationAccessIndex(context), - ]); + const notificationsKey = githubRevalidationSignalKeys.notifications; + const result = await getGitHubResourceLocalFirst({ + userId: context.session.user.id, + resource: "notifications.list.v1", + params: data, + freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [notificationsKey], + namespaceKeys: [notificationsKey], + cacheMode: "split", + executionContext: getBackgroundExecutionContext(requestContext), + onBackgroundRefreshSettled: () => + broadcastLocalFirstSignalKeys([notificationsKey]), + fetcher: async () => { + const [response, accessIndex] = await Promise.all([ + context.octokit.rest.activity.listNotificationsForAuthenticatedUser( + { + all: data.all ?? false, + participating: data.participating ?? false, + per_page: 50, + }, + ), + getInstallationAccessIndex(context), + ]); - // Batch-fetch participants for PR/Issue notifications in parallel - const participantMap = new Map(); - const stateMap = new Map(); - const fetchable = response.data.filter( - (n) => - n.subject.url && - (n.subject.type === "PullRequest" || n.subject.type === "Issue"), - ); + const participantMap = new Map(); + const stateMap = new Map(); + const fetchable = response.data.filter( + (n) => + n.subject.url && + (n.subject.type === "PullRequest" || n.subject.type === "Issue"), + ); - await Promise.allSettled( - fetchable.map(async (n) => { - try { - const seen = new Set(); - const participants: NotificationParticipant[] = []; - const add = (login: string, avatarUrl: string) => { - if (seen.has(login)) return; - seen.add(login); - participants.push({ login, avatarUrl }); - }; + await Promise.allSettled( + fetchable.map(async (n) => { + try { + const seen = new Set(); + const participants: NotificationParticipant[] = []; + const add = (login: string, avatarUrl: string) => { + if (seen.has(login)) return; + seen.add(login); + participants.push({ login, avatarUrl }); + }; - // Fetch subject detail + comments (and reviews for PRs) in parallel - const subjectUrl = n.subject.url!; - const commentsUrl = `${subjectUrl}/comments`; - const isPR = n.subject.type === "PullRequest"; - const reviewsUrl = isPR ? `${subjectUrl}/reviews` : null; - - const [subjectRes, commentsRes, reviewsRes] = await Promise.all([ - context.octokit.request("GET {url}", { url: subjectUrl }), - context.octokit - .request("GET {url}", { url: commentsUrl, per_page: 100 }) - .catch(() => null), - reviewsUrl - ? context.octokit - .request("GET {url}", { url: reviewsUrl, per_page: 100 }) - .catch(() => null) - : null, - ]); + const subjectUrl = n.subject.url; + if (!subjectUrl) { + return; + } + const commentsUrl = `${subjectUrl}/comments`; + const isPR = n.subject.type === "PullRequest"; + const reviewsUrl = isPR ? `${subjectUrl}/reviews` : null; + + const [subjectRes, commentsRes, reviewsRes] = await Promise.all( + [ + context.octokit.request("GET {url}", { url: subjectUrl }), + context.octokit + .request("GET {url}", { url: commentsUrl, per_page: 100 }) + .catch(() => null), + reviewsUrl + ? context.octokit + .request("GET {url}", { + url: reviewsUrl, + per_page: 100, + }) + .catch(() => null) + : null, + ], + ); - const d = subjectRes.data as { - user?: { login: string; avatar_url: string }; - assignees?: Array<{ login: string; avatar_url: string }>; - requested_reviewers?: Array<{ login: string; avatar_url: string }>; - state?: string; - merged?: boolean; - }; + const subjectData = subjectRes.data as { + user?: { login: string; avatar_url: string }; + assignees?: Array<{ login: string; avatar_url: string }>; + requested_reviewers?: Array<{ + login: string; + avatar_url: string; + }>; + state?: string; + merged?: boolean; + }; - // Extract subject state (open/closed/merged) - if (d.state) { - const state = d.merged - ? "merged" - : d.state === "closed" - ? "closed" - : "open"; - stateMap.set(n.id, state); - } + if (subjectData.state) { + const state = subjectData.merged + ? "merged" + : subjectData.state === "closed" + ? "closed" + : "open"; + stateMap.set(n.id, state); + } - if (d.user) add(d.user.login, d.user.avatar_url); - for (const a of d.assignees ?? []) add(a.login, a.avatar_url); - for (const r of d.requested_reviewers ?? []) - add(r.login, r.avatar_url); - - // Add commenters - if (commentsRes?.data && Array.isArray(commentsRes.data)) { - for (const c of commentsRes.data as Array<{ - user?: { login: string; avatar_url: string }; - }>) { - if (c.user) add(c.user.login, c.user.avatar_url); - } - } + if (subjectData.user) { + add(subjectData.user.login, subjectData.user.avatar_url); + } + for (const assignee of subjectData.assignees ?? []) { + add(assignee.login, assignee.avatar_url); + } + for (const reviewer of subjectData.requested_reviewers ?? []) { + add(reviewer.login, reviewer.avatar_url); + } - // Add reviewers (PRs only) - if (reviewsRes?.data && Array.isArray(reviewsRes.data)) { - for (const r of reviewsRes.data as Array<{ - user?: { login: string; avatar_url: string }; - }>) { - if (r.user) add(r.user.login, r.user.avatar_url); - } - } + if (commentsRes?.data && Array.isArray(commentsRes.data)) { + for (const comment of commentsRes.data as Array<{ + user?: { login: string; avatar_url: string }; + }>) { + if (comment.user) { + add(comment.user.login, comment.user.avatar_url); + } + } + } - participantMap.set(n.id, participants); - } catch { - // Silently skip — participant data is best-effort - } - }), - ); + if (reviewsRes?.data && Array.isArray(reviewsRes.data)) { + for (const review of reviewsRes.data as Array<{ + user?: { login: string; avatar_url: string }; + }>) { + if (review.user) { + add(review.user.login, review.user.avatar_url); + } + } + } - const notifications: NotificationItem[] = response.data.map((n) => ({ - id: n.id, - unread: n.unread, - reason: n.reason as NotificationItem["reason"], - subject: { - title: n.subject.title, - url: n.subject.url ?? null, - latestCommentUrl: n.subject.latest_comment_url ?? null, - type: n.subject.type as NotificationItem["subject"]["type"], - }, - repository: { - id: n.repository.id, - name: n.repository.name, - fullName: n.repository.full_name, - owner: { - login: n.repository.owner.login, - avatarUrl: n.repository.owner.avatar_url, - url: n.repository.owner.html_url ?? n.repository.owner.url, - type: n.repository.owner.type ?? "User", - }, - private: n.repository.private, - }, - participants: participantMap.get(n.id) ?? [], - subjectState: stateMap.get(n.id) ?? null, - updatedAt: n.updated_at, - lastReadAt: n.last_read_at ?? null, - url: n.url, - })); + participantMap.set(n.id, participants); + } catch { + // Participant enrichment is best-effort. + } + }), + ); - const filteredNotifications = notifications.filter((notification) => - isRepoVisibleWithInstallationAccess( - accessIndex, - notification.repository.owner.login, - notification.repository.name, - notification.repository.private, - ), - ); + const notifications: NotificationItem[] = response.data.map((n) => ({ + id: n.id, + unread: n.unread, + reason: n.reason as NotificationItem["reason"], + subject: { + title: n.subject.title, + url: n.subject.url ?? null, + latestCommentUrl: n.subject.latest_comment_url ?? null, + type: n.subject.type as NotificationItem["subject"]["type"], + }, + repository: { + id: n.repository.id, + name: n.repository.name, + fullName: n.repository.full_name, + owner: { + login: n.repository.owner.login, + avatarUrl: n.repository.owner.avatar_url, + url: n.repository.owner.html_url ?? n.repository.owner.url, + type: n.repository.owner.type ?? "User", + }, + private: n.repository.private, + }, + participants: participantMap.get(n.id) ?? [], + subjectState: stateMap.get(n.id) ?? null, + updatedAt: n.updated_at, + lastReadAt: n.last_read_at ?? null, + url: n.url, + })); + + const filteredNotifications = notifications.filter((notification) => + isRepoVisibleWithInstallationAccess( + accessIndex, + notification.repository.owner.login, + notification.repository.name, + notification.repository.private, + ), + ); - const removedCount = notifications.length - filteredNotifications.length; - if (removedCount > 0) { - debug("installation-access", "getNotifications filtered", { - total: notifications.length, - kept: filteredNotifications.length, - removed: removedCount, - removedRepos: [ - ...new Set( - notifications - .filter( - (n) => - !isRepoVisibleWithInstallationAccess( - accessIndex, - n.repository.owner.login, - n.repository.name, - n.repository.private, - ), - ) - .map((n) => n.repository.fullName), - ), - ], + const removedCount = + notifications.length - filteredNotifications.length; + if (removedCount > 0) { + debug("installation-access", "getNotifications filtered", { + total: notifications.length, + kept: filteredNotifications.length, + removed: removedCount, + removedRepos: [ + ...new Set( + notifications + .filter( + (n) => + !isRepoVisibleWithInstallationAccess( + accessIndex, + n.repository.owner.login, + n.repository.name, + n.repository.private, + ), + ) + .map((n) => n.repository.fullName), + ), + ], + }); + } + + return { + kind: "success", + data: { notifications: filteredNotifications }, + metadata: createGitHubResponseMetadata( + response.status, + normalizeResponseHeaders(response.headers), + ), + }; + }, }); - } - return { notifications: filteredNotifications }; - }); + return withLocalFirstMeta(result.data, result.meta); + }, + ); type MarkNotificationReadInput = { threadId: string }; diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 4894171..f108c3f 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -134,6 +134,7 @@ export type MyPullsResult = { forbiddenOrgs?: string[]; partial?: boolean; timedOut?: boolean; + __meta?: GitHubLocalFirstMeta; }; export type MyIssuesResult = { @@ -143,6 +144,7 @@ export type MyIssuesResult = { forbiddenOrgs?: string[]; partial?: boolean; timedOut?: boolean; + __meta?: GitHubLocalFirstMeta; }; export type CommandPaletteSearchResult = { @@ -241,6 +243,7 @@ export type IssuePageData = { events: TimelineEvent[]; commentPagination: CommentPagination; eventPagination: EventPagination; + __meta?: GitHubLocalFirstMeta; }; export type PullCheckRun = { @@ -385,6 +388,7 @@ export type PullStatus = { canUpdateBranch: boolean; canBypassProtections: boolean; canMerge: boolean; + __meta?: GitHubLocalFirstMeta; }; export type PullCommit = { @@ -413,6 +417,7 @@ export type PullPageData = { commentPagination: CommentPagination; eventPagination: EventPagination; headRefDeleted: boolean; + __meta?: GitHubLocalFirstMeta; }; export type PullFile = { @@ -649,6 +654,7 @@ export type RepoOverview = { date: string; author: GitHubActor | null; } | null; + __meta?: GitHubLocalFirstMeta; }; export type RepoTreeEntry = { @@ -804,6 +810,13 @@ export type NotificationItem = { url: string; }; +export type GitHubLocalFirstMeta = { + cacheStatus: "fresh" | "stale" | "miss"; + fetchedAt: number | null; + isRevalidating: boolean; +}; + export type NotificationsResult = { notifications: NotificationItem[]; + __meta?: GitHubLocalFirstMeta; }; diff --git a/apps/dashboard/src/lib/use-github-signal-stream.test.ts b/apps/dashboard/src/lib/use-github-signal-stream.test.ts new file mode 100644 index 0000000..c753995 --- /dev/null +++ b/apps/dashboard/src/lib/use-github-signal-stream.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + getGitHubDataFetchedAt, + getGitHubSignalComparisonTimestamp, +} from "./use-github-signal-stream"; + +describe("use-github-signal-stream helpers", () => { + it("prefers the cached GitHub fetchedAt over React Query dataUpdatedAt", () => { + expect( + getGitHubSignalComparisonTimestamp({ + data: { + __meta: { + cacheStatus: "stale", + fetchedAt: 1_000, + isRevalidating: true, + }, + }, + dataUpdatedAt: 9_000, + }), + ).toBe(1_000); + }); + + it("falls back to React Query dataUpdatedAt when local-first meta is absent", () => { + expect( + getGitHubSignalComparisonTimestamp({ + data: { value: true }, + dataUpdatedAt: 9_000, + }), + ).toBe(9_000); + }); + + it("returns null when the query has never been populated", () => { + expect( + getGitHubSignalComparisonTimestamp({ + data: null, + dataUpdatedAt: 0, + }), + ).toBeNull(); + }); + + it("ignores malformed local-first meta", () => { + expect( + getGitHubDataFetchedAt({ __meta: { fetchedAt: "oops" } }), + ).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 6b647b7..aa88251 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -29,6 +29,39 @@ function isSignalMessage(data: unknown): data is SignalMessage { const RECONNECT_DELAY_MS = 3_000; /** Fallback when WebSocket misses — keep "My" lists reasonably fresh */ const POLL_INTERVAL_MS = 90 * 1_000; +const RESUME_SYNC_MIN_INTERVAL_MS = 5_000; + +export function getGitHubDataFetchedAt(value: unknown): number | null { + if (!value || typeof value !== "object" || !("__meta" in value)) { + return null; + } + + const meta = value.__meta; + if (!meta || typeof meta !== "object" || !("fetchedAt" in meta)) { + return null; + } + + return typeof meta.fetchedAt === "number" ? meta.fetchedAt : null; +} + +export function getGitHubSignalComparisonTimestamp( + queryState: + | { + data?: unknown; + dataUpdatedAt: number; + } + | null + | undefined, +) { + if (!queryState || queryState.dataUpdatedAt === 0) { + return null; + } + + return ( + getGitHubDataFetchedAt(queryState.data) ?? + (queryState.dataUpdatedAt > 0 ? queryState.dataUpdatedAt : null) + ); +} function tryGitHubQueryScopeFromTargets( targets: readonly GitHubSignalStreamTarget[], @@ -160,10 +193,15 @@ function collectKeysToInvalidateAfterServerSync( signal.signalKey, ); const lastSeen = lastSeenTimestamps.get(compositeKey); - const qs = queryClient.getQueryState(target.queryKey); + const freshnessTimestamp = getGitHubSignalComparisonTimestamp( + queryClient.getQueryState(target.queryKey), + ); if (lastSeen === undefined) { - if (qs && qs.dataUpdatedAt > 0 && signal.updatedAt > qs.dataUpdatedAt) { + if ( + typeof freshnessTimestamp === "number" && + signal.updatedAt > freshnessTimestamp + ) { updatedKeys.add(signal.signalKey); } lastSeenTimestamps.set(compositeKey, signal.updatedAt); @@ -195,9 +233,15 @@ function useGitHubSignalStreamWebSocket( let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let disposed = false; + let lastResumeSyncAt = 0; async function syncSignalsFromServer(source: string) { try { + debug(source, "attempting catch-up", { + signalKeys: keys, + totalTargets: targetsRef.current.length, + }); + const signals = await getRevalidationSignalTimestamps({ data: { signalKeys: keys }, }); @@ -235,6 +279,26 @@ function useGitHubSignalStreamWebSocket( } } + function scheduleResumeSync(source: string) { + const now = Date.now(); + if (now - lastResumeSyncAt < RESUME_SYNC_MIN_INTERVAL_MS) { + debug(source, "skipping catch-up attempt", { + reason: "throttled", + signalKeys: keys, + lastResumeSyncAt, + now, + }); + return; + } + + lastResumeSyncAt = now; + debug(source, "scheduling catch-up attempt", { + signalKeys: keys, + totalTargets: targetsRef.current.length, + }); + void syncSignalsFromServer(source); + } + function sendSubscription(socket: WebSocket) { if (socket.readyState === WebSocket.OPEN) { debug("github-signal-stream", "subscribing to signal keys", { @@ -325,10 +389,30 @@ function useGitHubSignalStreamWebSocket( reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); } + function handleVisibilityChange() { + if (document.visibilityState === "visible") { + scheduleResumeSync("github-signal-visibility-catchup"); + } + } + + function handleWindowFocus() { + scheduleResumeSync("github-signal-focus-catchup"); + } + + function handleOnline() { + scheduleResumeSync("github-signal-online-catchup"); + } + connect(); + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("online", handleOnline); return () => { disposed = true; + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("online", handleOnline); if (reconnectTimer) { clearTimeout(reconnectTimer); } From 18ac0e6e0c3c0f207bafe8b321e8490754264718 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 25 Apr 2026 11:34:56 -0400 Subject: [PATCH 2/3] fix(dashboard): unblock cache test suite --- .../src/lib/github-cache-invalidation.test.ts | 24 ++++++++++++++----- apps/dashboard/src/lib/github-cache.ts | 16 +++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/lib/github-cache-invalidation.test.ts b/apps/dashboard/src/lib/github-cache-invalidation.test.ts index 7ca79ea..720e7ad 100644 --- a/apps/dashboard/src/lib/github-cache-invalidation.test.ts +++ b/apps/dashboard/src/lib/github-cache-invalidation.test.ts @@ -13,6 +13,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }), ).toEqual([ "pulls.mine", + "notifications", "repoMeta:stylessh/havana", "pull:stylessh/havana#42", ]); @@ -32,7 +33,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }, }, }), - ).toEqual(["pull:stylessh/havana#7"]); + ).toEqual(["notifications", "pull:stylessh/havana#7"]); }); it("maps plain issues webhook events to issue list and detail signals", () => { @@ -48,6 +49,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }), ).toEqual([ "issues.mine", + "notifications", "repoMeta:stylessh/havana", "issue:stylessh/havana#9", ]); @@ -61,7 +63,11 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { owner: { login: "stylessh" }, }, }), - ).toEqual(["repoMeta:stylessh/havana", "repoCode:stylessh/havana"]); + ).toEqual([ + "notifications", + "repoMeta:stylessh/havana", + "repoCode:stylessh/havana", + ]); }); it("extracts pull signals from check_run webhook payloads", () => { @@ -89,7 +95,11 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { id: 101, }, }), - ).toEqual(["actions:stylessh/havana", "workflowRun:stylessh/havana#101"]); + ).toEqual([ + "notifications", + "actions:stylessh/havana", + "workflowRun:stylessh/havana#101", + ]); }); it("maps workflow_job webhook events to repo, run, and job signals", () => { @@ -105,6 +115,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }, }), ).toEqual([ + "notifications", "actions:stylessh/havana", "workflowRun:stylessh/havana#101", "workflowJob:stylessh/havana#202", @@ -119,7 +130,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { owner: { login: "stylessh" }, }, }), - ).toEqual(["repoProtection:stylessh/havana"]); + ).toEqual(["notifications", "repoProtection:stylessh/havana"]); }); it("maps branch_protection_rule events to repo protection signal", () => { @@ -130,7 +141,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { owner: { login: "stylessh" }, }, }), - ).toEqual(["repoProtection:stylessh/havana"]); + ).toEqual(["notifications", "repoProtection:stylessh/havana"]); }); it("maps status events to repo statuses signal", () => { @@ -142,7 +153,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }, sha: "abc123", }), - ).toEqual(["repoStatuses:stylessh/havana"]); + ).toEqual(["notifications", "repoStatuses:stylessh/havana"]); }); it("extracts pull signals from workflow_run payloads alongside run entity", () => { @@ -158,6 +169,7 @@ describe("getGitHubWebhookRevalidationSignalKeys", () => { }, }), ).toEqual([ + "notifications", "actions:stylessh/havana", "workflowRun:stylessh/havana#55", "pull:stylessh/havana#17", diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts index 8300e1c..2952268 100644 --- a/apps/dashboard/src/lib/github-cache.ts +++ b/apps/dashboard/src/lib/github-cache.ts @@ -880,11 +880,17 @@ export async function getOrRevalidateGitHubResource({ getNamespaceVersions, now, }); - resolvedStore = await resolved.getResolvedStore(); const existingEntry = resolved.existingEntry; const currentTime = resolved.currentTime; const payloadStorageKey = resolved.payloadStorageKey; const isSignalNewerThanCache = resolved.isSignalNewerThanCache; + const getLegacyStore = async () => { + if (!resolvedStore) { + resolvedStore = await resolved.getResolvedStore(); + } + + return resolvedStore; + }; if ( existingEntry && @@ -910,7 +916,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: staleEntry, - legacyStore: resolvedStore, + legacyStore: await getLegacyStore(), payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -928,7 +934,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: staleEntry, - legacyStore: resolvedStore, + legacyStore: await getLegacyStore(), payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -963,7 +969,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: refreshedEntry, - legacyStore: resolvedStore, + legacyStore: await getLegacyStore(), payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, @@ -999,7 +1005,7 @@ export async function getOrRevalidateGitHubResource({ await persistGitHubCacheEntry({ entry: nextEntry, - legacyStore: resolvedStore, + legacyStore: await getLegacyStore(), payloadStore: resolvedPayloadStore, payloadStorageKey, payloadRetentionSeconds, From 3b011515103af672aecdbf4b94cce3beab08980a Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 25 Apr 2026 12:06:15 -0400 Subject: [PATCH 3/3] improve dashboard live responsiveness --- .../components/details/detail-activity.tsx | 8 ++ .../pulls/detail/pull-detail-activity.tsx | 9 ++ .../components/repo/repo-activity-cards.tsx | 80 +++++++++++++ .../components/repo/repo-overview-page.tsx | 14 +++ .../dashboard/src/lib/github-query-updates.ts | 112 ++++++++++++++++++ .../dashboard/src/routes/_protected/index.tsx | 56 +++++++-- 6 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/src/lib/github-query-updates.ts diff --git a/apps/dashboard/src/components/details/detail-activity.tsx b/apps/dashboard/src/components/details/detail-activity.tsx index cf12a22..9bf617d 100644 --- a/apps/dashboard/src/components/details/detail-activity.tsx +++ b/apps/dashboard/src/components/details/detail-activity.tsx @@ -35,6 +35,7 @@ import { githubViewerQueryOptions, } from "#/lib/github.query"; import type { GitHubActor } from "#/lib/github.types"; +import { removePullFromOpenViews } from "#/lib/github-query-updates"; import { checkPermissionWarning } from "#/lib/warning-store"; export function DetailActivityHeader({ @@ -180,6 +181,13 @@ export function DetailCommentBox({ }, }); if (result.ok) { + if (newState === "closed") { + removePullFromOpenViews(queryClient, scope, { + owner, + repo, + pullNumber: issueNumber, + }); + } void queryClient.invalidateQueries({ queryKey: githubQueryKeys.all, }); diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index a87d16f..8a9b020 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -112,6 +112,7 @@ import type { PullWorkflowApproval, TimelineEvent, } from "#/lib/github.types"; +import { removePullFromOpenViews } from "#/lib/github-query-updates"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { mergeIssueStateIntoCloseEvent, @@ -586,6 +587,7 @@ function MergeStatusCard({ {/* Merge action footer */} ; @@ -1398,6 +1402,11 @@ function MergeFooter({ }, }); if (result.ok) { + removePullFromOpenViews(queryClient, scope, { + owner, + repo, + pullNumber, + }); await queryClient.invalidateQueries({ queryKey: ["github"] }); } else { toast.error(result.error); diff --git a/apps/dashboard/src/components/repo/repo-activity-cards.tsx b/apps/dashboard/src/components/repo/repo-activity-cards.tsx index 7de17ee..c2a0e1d 100644 --- a/apps/dashboard/src/components/repo/repo-activity-cards.tsx +++ b/apps/dashboard/src/components/repo/repo-activity-cards.tsx @@ -9,6 +9,7 @@ import { import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; import { CheckStateIcon, getCheckState, @@ -18,6 +19,7 @@ import { type GitHubQueryScope, githubIssuesFromRepoQueryOptions, githubPullsFromRepoQueryOptions, + githubQueryKeys, githubRepoDiscussionsQueryOptions, githubWorkflowRunsFromRepoQueryOptions, } from "#/lib/github.query"; @@ -28,7 +30,9 @@ import type { RepoOverview, WorkflowRun, } from "#/lib/github.types"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { getPrStateConfig } from "#/lib/pr-state"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; export function RepoActivityCards({ @@ -93,6 +97,82 @@ export function RepoActivityCards({ discussionsData && !Array.isArray(discussionsData) ? discussionsData.totalCount : undefined; + const webhookRefreshTargets = useMemo(() => { + const repoInput = { owner, repo }; + const pullSignalKeys = new Set([ + githubRevalidationSignalKeys.repoMeta(repoInput), + ]); + const issueSignalKeys = new Set([ + githubRevalidationSignalKeys.repoMeta(repoInput), + ]); + const runSignalKeys = new Set([ + githubRevalidationSignalKeys.actionsRepo(repoInput), + ]); + + for (const pull of pullsQuery.data ?? []) { + pullSignalKeys.add( + githubRevalidationSignalKeys.pullEntity({ + owner: pull.repository.owner, + repo: pull.repository.name, + pullNumber: pull.number, + }), + ); + } + + for (const issue of issuesQuery.data ?? []) { + issueSignalKeys.add( + githubRevalidationSignalKeys.issueEntity({ + owner: issue.repository.owner, + repo: issue.repository.name, + issueNumber: issue.number, + }), + ); + } + + for (const run of runsQuery.data ?? []) { + runSignalKeys.add( + githubRevalidationSignalKeys.workflowRunEntity({ + owner, + repo, + runId: run.id, + }), + ); + } + + return [ + { + queryKey: githubQueryKeys.pulls.repo(scope, { + owner, + repo, + state: "open", + perPage: 5, + sort: "updated", + direction: "desc", + }), + signalKeys: Array.from(pullSignalKeys), + }, + { + queryKey: githubQueryKeys.issues.repo(scope, { + owner, + repo, + state: "open", + perPage: 5, + sort: "updated", + direction: "desc", + }), + signalKeys: Array.from(issueSignalKeys), + }, + { + queryKey: githubQueryKeys.actions.runsList(scope, { + owner, + repo, + perPage: 5, + }), + signalKeys: Array.from(runSignalKeys), + }, + ]; + }, [issuesQuery.data, owner, pullsQuery.data, repo, runsQuery.data, scope]); + useGitHubSignalStream(webhookRefreshTargets); return (
diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index 53e9b9e..ffa1a85 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -4,9 +4,12 @@ import { useCallback } from "react"; import { SidePanelPortal } from "#/components/layouts/dashboard-side-panel"; import { type GitHubQueryScope, + githubQueryKeys, githubRepoOverviewQueryOptions, githubRepoTreeQueryOptions, } from "#/lib/github.query"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; import { BranchComparisonBanner } from "./branch-comparison-banner"; @@ -38,6 +41,17 @@ export function RepoOverviewPage({ ...githubRepoOverviewQueryOptions(scope, { owner, repo }), enabled: hasMounted, }); + const overviewInput = { owner, repo }; + const overviewRefreshTargets = [ + { + queryKey: githubQueryKeys.repo.overview(scope, overviewInput), + signalKeys: [ + githubRevalidationSignalKeys.repoMeta(overviewInput), + githubRevalidationSignalKeys.repoCode(overviewInput), + ], + }, + ]; + useGitHubSignalStream(overviewRefreshTargets); const repoData = overviewQuery.data; const activeRef = currentRef ?? repoData?.defaultBranch ?? "main"; diff --git a/apps/dashboard/src/lib/github-query-updates.ts b/apps/dashboard/src/lib/github-query-updates.ts new file mode 100644 index 0000000..185697f --- /dev/null +++ b/apps/dashboard/src/lib/github-query-updates.ts @@ -0,0 +1,112 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { type GitHubQueryScope, githubQueryKeys } from "./github.query"; +import type { MyPullsResult, PullSummary, RepoOverview } from "./github.types"; + +function matchesPull( + pull: Pick, + owner: string, + repo: string, + pullNumber: number, +) { + return ( + pull.number === pullNumber && + pull.repository.owner === owner && + pull.repository.name === repo + ); +} + +export function removePullFromOpenViews( + queryClient: QueryClient, + scope: GitHubQueryScope, + input: { owner: string; repo: string; pullNumber: number }, +) { + const { owner, repo, pullNumber } = input; + + queryClient.setQueryData( + githubQueryKeys.pulls.mine(scope), + (current) => { + if (!current) { + return current; + } + + return { + ...current, + reviewRequested: current.reviewRequested.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ), + assigned: current.assigned.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ), + authored: current.authored.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ), + mentioned: current.mentioned.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ), + involved: current.involved.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ), + }; + }, + ); + + queryClient.setQueriesData( + { + predicate: (query) => { + const key = query.queryKey; + if (!Array.isArray(key) || key.length < 5) { + return false; + } + + if ( + key[0] !== "github" || + key[1] !== scope.userId || + key[2] !== "pulls" || + key[3] !== "repo" + ) { + return false; + } + + const queryInput = key[4]; + if (!queryInput || typeof queryInput !== "object") { + return false; + } + + const repoInput = queryInput as { + owner?: string; + repo?: string; + state?: string; + }; + + return ( + repoInput.owner === owner && + repoInput.repo === repo && + (repoInput.state === undefined || repoInput.state === "open") + ); + }, + }, + (current) => { + if (!current) { + return current; + } + + return current.filter( + (pull) => !matchesPull(pull, owner, repo, pullNumber), + ); + }, + ); + + queryClient.setQueryData( + githubQueryKeys.repo.overview(scope, { owner, repo }), + (current) => { + if (!current) { + return current; + } + + return { + ...current, + openPullCount: Math.max(0, current.openPullCount - 1), + }; + }, + ); +} diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx index 466f206..3bf2f2a 100644 --- a/apps/dashboard/src/routes/_protected/index.tsx +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -45,19 +45,57 @@ function OverviewPage() { ...githubMyIssuesQueryOptions(scope), enabled: hasMounted, }); - const webhookTargets = useMemo( - () => [ + const webhookTargets = useMemo(() => { + const pulls = pullsQuery.data; + const issues = issuesQuery.data; + const pullSignalKeys = new Set([ + githubRevalidationSignalKeys.pullsMine, + ]); + const issueSignalKeys = new Set([ + githubRevalidationSignalKeys.issuesMine, + ]); + + for (const pull of [ + ...(pulls?.reviewRequested ?? []), + ...(pulls?.assigned ?? []), + ...(pulls?.authored ?? []), + ...(pulls?.mentioned ?? []), + ...(pulls?.involved ?? []), + ]) { + pullSignalKeys.add( + githubRevalidationSignalKeys.pullEntity({ + owner: pull.repository.owner, + repo: pull.repository.name, + pullNumber: pull.number, + }), + ); + } + + for (const issue of [ + ...(issues?.assigned ?? []), + ...(issues?.authored ?? []), + ...(issues?.mentioned ?? []), + ]) { + issueSignalKeys.add( + githubRevalidationSignalKeys.issueEntity({ + owner: issue.repository.owner, + repo: issue.repository.name, + issueNumber: issue.number, + }), + ); + } + + return [ { queryKey: githubQueryKeys.pulls.mine(scope), - signalKeys: [githubRevalidationSignalKeys.pullsMine], + signalKeys: Array.from(pullSignalKeys), }, { queryKey: githubQueryKeys.issues.mine(scope), - signalKeys: [githubRevalidationSignalKeys.issuesMine], + signalKeys: Array.from(issueSignalKeys), }, - ], - [scope], - ); + ]; + }, [issuesQuery.data, pullsQuery.data, scope]); useGitHubSignalStream(webhookTargets); if (pullsQuery.error) throw pullsQuery.error; @@ -88,7 +126,9 @@ function OverviewPage() { }, ]; - const recentPulls = pulls.authored.slice(0, 10); + const recentPulls = [...pulls.authored] + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) + .slice(0, 10); return (