diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..4063e013006 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -148,7 +148,6 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/github.ts inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 211a73bd2f6..250f4a43468 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -30,7 +30,7 @@ import { cn } from '@/lib/utils' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' -import { projectViewCacheKey } from '@/store/slices/github' +import { projectViewCacheKey } from '@/store/github/cache-identity' import type { GitHubIssueType, GitHubProjectFieldMutationValue, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 7c81834319c..6201871ab3a 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -15,11 +15,10 @@ import { import { useAppStore, type AppState } from '@/store' import { buildGitHubPRRefreshStateClearToken, - getGitHubPRRefreshStateExpiryAt, - mergePRCommentIntoList, - prChecksCacheSuffix, - prCommentsCacheSuffix -} from '@/store/slices/github' + getGitHubPRRefreshStateExpiryAt +} from '@/store/github/pr-refresh-state' +import { mergePRCommentIntoList } from '@/store/github/pr-comment-cache' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/github/cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 4ee957c5001..90c17779112 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -12,7 +12,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' type MockStoreState = { activeWorktreeId: string | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts index 325c7409246..ae20fa2aba0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { PRRefreshState } from '@/store/slices/github' +import type { PRRefreshState } from '@/store/github/pr-refresh-state' import { buildChecksPanelPRRefreshBreadcrumbData, recordChecksPanelPRRefreshBreadcrumb diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts index d2925dc64c3..c3d4f2b35a8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts @@ -1,5 +1,8 @@ import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' -import { getGitHubPRRefreshStateExpiryAt, type PRRefreshState } from '@/store/slices/github' +import { + getGitHubPRRefreshStateExpiryAt, + type PRRefreshState +} from '@/store/github/pr-refresh-state' import type { CrashReportBreadcrumbData, CrashReportDetailValue diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index 4a071f6638e..f44e7dca663 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -10,7 +10,7 @@ import { getGitHubRepoCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, getParentPrChecksRefreshIdentity, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index 10d0367f620..d3c2480514e 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -8,7 +8,7 @@ import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr- import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { PARENT_PR_CHECKS_GROUP_LABELS, PARENT_PR_CHECKS_GROUP_ORDER, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 23129542be2..05bee14ee80 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -1,7 +1,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { useAppStore } from '@/store' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import type { HostedReviewInfo } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts index bf1e0a1bb31..4a3aba42542 100644 --- a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { useMountedRef } from '@/hooks/useMountedRef' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear/links' diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 890e7c81d7d..c4d8b080843 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -55,7 +55,7 @@ import { getWorktreeOnHostFromState } from '../../store/selectors' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' -import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getSelectedDeletableWorkspaceRows, diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 968189a5417..44777236862 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { CacheEntry } from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 403a8a97dd1..a563dcae7b1 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -1,9 +1,9 @@ -import { - workItemsCacheKey, - type CacheEntry, - type WorkItemsCacheError, - type WorkItemsCacheSources -} from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { + CacheEntry, + WorkItemsCacheError, + WorkItemsCacheSources +} from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { taskPageWorkItemKey, diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 969160f13f8..7dfc8075ade 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts index 31ac2730b27..4114e52c4c8 100644 --- a/src/renderer/src/components/task-page-linear-cache-selectors.ts +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' import { sortedStrings } from './task-page-work-item-signatures' diff --git a/src/renderer/src/lib/worktree-palette-document.ts b/src/renderer/src/lib/worktree-palette-document.ts index e99bf0e781a..4d00368c33e 100644 --- a/src/renderer/src/lib/worktree-palette-document.ts +++ b/src/renderer/src/lib/worktree-palette-document.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { buildPaletteDocument, type PaletteDocument } from './palette-match/palette-document' import type { PaletteComposedEvidence } from './palette-match/evidence-composer' import { diff --git a/src/renderer/src/store/github/cache-identity.ts b/src/renderer/src/store/github/cache-identity.ts new file mode 100644 index 00000000000..88eb8a4e73e --- /dev/null +++ b/src/renderer/src/store/github/cache-identity.ts @@ -0,0 +1,227 @@ +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '../slices/github-cache-key' +import type { AppState } from '../types' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { CacheEntry } from './cache-model' + +function queryOverrideKeyPart(queryOverride: string | undefined): string { + return queryOverride === undefined ? '' : `:q=${queryOverride}` +} + +export function projectViewCacheKey( + ownerType: GetProjectViewTableArgs['ownerType'], + owner: string, + projectNumber: number, + resolvedViewId: string, + queryOverride?: string, + sourceScope = 'local', + host?: string +): string { + const projectKey = githubProjectIdentityKey({ ownerType, owner, number: projectNumber, host }) + return `github-project:${sourceScope}:${projectKey}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` +} + +export function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { + const selector = args.viewId + ? `id:${args.viewId}` + : args.viewNumber !== undefined + ? `num:${args.viewNumber}` + : args.viewName + ? `name:${args.viewName}` + : 'default' + const projectKey = githubProjectIdentityKey({ + ownerType: args.ownerType, + owner: args.owner, + number: args.projectNumber, + host: args.host + }) + return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` +} + +export function projectViewSourceScope(settings: AppState['settings']): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} + +export function settingsForProjectViewCacheKey( + settings: AppState['settings'], + cacheKey: string +): Pick, 'activeRuntimeEnvironmentId'> { + const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) + return runtimeMatch + ? { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } + : { ...settings, activeRuntimeEnvironmentId: null } +} + +export function workItemsCacheKey( + repoId: string, + limit: number, + query: string, + executionHostId?: string | null +): string { + const scope = executionHostId?.trim() ?? '' + const hostId = normalizeExecutionHostId(scope) + const owner = `${repoId}::${limit}::${query}` + if (hostId) { + return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + } + return scope ? `${scope}::${owner}` : owner +} + +export function workItemsInflightRequestKey( + cacheKey: string, + target: { kind: 'environment'; environmentId: string; runtimeRepoId: string } | { kind: 'local' } +): string { + const targetPart = + target.kind === 'environment' ? `env:${target.environmentId}:${target.runtimeRepoId}` : 'local' + return `${cacheKey}::${targetPart}` +} + +export function issueCacheKey( + repoPath: string, + repoId: string | undefined, + issueNumber: number | string, + settings?: Pick | null, + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + String(issueNumber), + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function runtimeScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function sourceScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + sourceContext?: TaskSourceContext | null, + hasRepoOwner = false +): string { + if (sourceContext?.provider === 'github') { + return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` + } + return runtimeScopedRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function prCacheKey( + repoPath: string, + repoId: string | undefined, + branch: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubPRCacheKey( + repoPath, + repoId, + branch, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { + const prefixes = [`${repoId}::`] + if (repoPath && repoPath !== repoId) { + prefixes.push(`${repoPath}::`) + } + return prefixes +} + +export function matchesRepoCacheKey(key: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => key.startsWith(prefix)) +} + +export function evictRepoCacheEntries( + cache: Record>, + prefixes: readonly string[] +): { cache: Record>; evicted: boolean } { + let next: Record> | null = null + for (const key of Object.keys(cache)) { + if (!matchesRepoCacheKey(key, prefixes)) { + continue + } + next ??= { ...cache } + delete next[key] + } + return next ? { cache: next, evicted: true } : { cache, evicted: false } +} + +function normalizedHeadSha(headSha?: string): string | null { + const trimmed = headSha?.trim() + return trimmed ? trimmed.toLowerCase() : null +} + +export function prChecksCacheSuffix( + prNumber: number, + prRepo?: GitHubOwnerRepo | null, + headSha?: string +): string { + const headSuffix = normalizedHeadSha(headSha) + const base = prRepo + ? `pr-checks::${githubRepoIdentityKey(prRepo)}::${prNumber}` + : `pr-checks::${prNumber}` + return headSuffix ? `${base}::head::${headSuffix}` : base +} + +export function prCommentsCacheSuffix(prNumber: number, prRepo?: GitHubOwnerRepo | null): string { + return prRepo + ? `pr-comments::${githubRepoIdentityKey(prRepo)}::${prNumber}` + : `pr-comments::${prNumber}` +} + +export function getPRChecksCacheTtl(entry: CacheEntry | undefined): number { + return entry?.data?.length === 0 ? 10_000 : 60_000 +} diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts new file mode 100644 index 00000000000..27db1bf8167 --- /dev/null +++ b/src/renderer/src/store/github/cache-model.ts @@ -0,0 +1,64 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-request-refresh-types' +import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' + +export type WorkItemsCacheSources = { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + /** Raw origin remote, kept distinct from the effective PR source. */ + originCandidate: GitHubOwnerRepo | null + /** Raw upstream remote, kept distinct from the effective issue source. */ + upstreamCandidate: GitHubOwnerRepo | null +} + +export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } + +export type CacheEntry = { + data: T | null + fetchedAt: number + headSha?: string + sources?: WorkItemsCacheSources + error?: WorkItemsCacheError + issueSourceFellBack?: true +} + +export type FetchOptions = { + force?: boolean + noCache?: boolean + requireComplete?: boolean + allowStaleFallback?: boolean + sourceContext?: TaskSourceContext | null +} + +export type RepoScopedFetchOptions = FetchOptions & { repoId?: string } + +export type GitHubPRFallbackSource = NonNullable + +export type ProjectViewCacheEntry = { + data: T | null + fetchedAt: number + error?: GitHubProjectViewError +} + +export type ProjectRowContentUpdate = { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] +} + +export type ProjectRowContentPatch = { + title?: string + body?: string + state?: 'open' | 'closed' | 'merged' | 'draft' + labels?: string[] + assignees?: string[] +} + +export type GitHubPatchWorkItemOptions = { + sourceContext?: TaskSourceContext | null +} diff --git a/src/renderer/src/store/github/cache-persistence.ts b/src/renderer/src/store/github/cache-persistence.ts new file mode 100644 index 00000000000..06337e94a11 --- /dev/null +++ b/src/renderer/src/store/github/cache-persistence.ts @@ -0,0 +1,17 @@ +import type { AppState } from '../types' + +let saveTimer: ReturnType | null = null + +/** Schedules the renderer's single trailing GitHub cache persistence write. */ +export function debouncedSaveCache(state: AppState): void { + clearTimeout(saveTimer ?? undefined) + saveTimer = setTimeout(() => { + saveTimer = null + window.api.cache.setGitHub({ + cache: { + pr: state.prCache, + issue: state.issueCache + } + }) + }, 1000) +} diff --git a/src/renderer/src/store/github/cache-policy.ts b/src/renderer/src/store/github/cache-policy.ts new file mode 100644 index 00000000000..2296f37f208 --- /dev/null +++ b/src/renderer/src/store/github/cache-policy.ts @@ -0,0 +1,50 @@ +import type { CacheEntry } from './cache-model' + +export const CACHE_TTL = 300_000 +export const WORK_ITEMS_CACHE_TTL = 60_000 +export const GITHUB_SEARCH_RESULT_WINDOW = 1000 +export const ERROR_TOAST_DURATION = 60_000 +export const MAX_CACHE_ENTRIES = 500 + +export function isFresh( + entry: CacheEntry | undefined, + ttl = CACHE_TTL +): entry is CacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < ttl +} + +/** Evicts the oldest entries only when over the bound; age alone does not evict. */ +export function evictStaleEntries( + cache: Record, + maxEntries = MAX_CACHE_ENTRIES +): Record { + const entries = Object.entries(cache) + if (entries.length <= maxEntries) { + return cache + } + entries.sort(([, a], [, b]) => b.fetchedAt - a.fetchedAt) + return Object.fromEntries(entries.slice(0, maxEntries)) +} + +export function withBoundedCacheEntry( + cache: Record, + key: string, + entry: T +): Record { + return evictStaleEntries({ ...cache, [key]: entry }) +} + +export function capRecordByInsertionOrder( + record: Record, + maxEntries = MAX_CACHE_ENTRIES +): Record { + const keys = Object.keys(record) + if (keys.length <= maxEntries) { + return record + } + const next = { ...record } + for (let i = 0; i < keys.length - maxEntries; i += 1) { + delete next[keys[i]] + } + return next +} diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts new file mode 100644 index 00000000000..d29a3720d38 --- /dev/null +++ b/src/renderer/src/store/github/check-actions.ts @@ -0,0 +1,231 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' +import { syncPRChecksStatus } from '../slices/github-checks' +import { + getPRChecksCacheTtl, + prChecksCacheSuffix, + sourceScopedRepoCacheKey +} from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { debouncedSaveCache } from './cache-persistence' +import { inflightChecksRequests } from './request-coordination' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCheckActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRChecks: async ( + repoPath, + prNumber, + branch, + headSha, + prRepo, + options + ): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prChecksCacheSuffix(prNumber, prRepo, headSha), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const legacyCacheKey = headSha + ? sourceScopedRepoCacheKey( + repoPath, + repoId, + prChecksCacheSuffix(prNumber, prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + : cacheKey + const inflightKey = cacheKey + const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] + if ( + !options?.force && + !options?.noCache && + isFresh(cached, getPRChecksCacheTtl(cached)) && + (!headSha || cached.headSha === headSha) + ) { + const cachedChecks = cached.data ?? [] + const prStatusUpdate = syncPRChecksStatus( + get(), + repoPath, + repoId, + branch, + cachedChecks, + cached.headSha, + prRepo, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + if (prStatusUpdate) { + set(prStatusUpdate) + debouncedSaveCache(get()) + } + return cachedChecks + } + + const inflightRequest = inflightChecksRequests.get(inflightKey) + if (inflightRequest) { + if ( + (options?.force && !inflightRequest.force) || + (options?.noCache && !inflightRequest.noCache) + ) { + await inflightRequest.promise.catch(() => {}) + } else { + return inflightRequest.promise + } + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const checks = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prChecks', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache) + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + repoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache), + sourceContext: options?.sourceContext + })) as PRCheckDetail[]) + set((s) => { + const nextState: Partial = { + checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { + data: checks, + fetchedAt: Date.now(), + headSha + }) + } + + const prStatusUpdate = syncPRChecksStatus( + s, + repoPath, + repoId, + branch, + checks, + headSha, + prRepo, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + if (prStatusUpdate?.prCache) { + nextState.prCache = prStatusUpdate.prCache + } + + return nextState + }) + debouncedSaveCache(get()) + return checks + } catch (err) { + console.error('Failed to fetch PR checks:', err) + const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] + if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) { + return latestCached.data + } + return [] + } finally { + inflightChecksRequests.delete(inflightKey) + } + })() + + inflightChecksRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.force || options?.noCache) + }) + return request + }, + + fetchPRCheckDetails: async (repoPath, args, options): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const requestTarget = requestContext.target + return requestTarget.kind === 'environment' + ? await withGitHubCheckDetailsTimeout((signal) => + callRuntimeRpc( + { kind: 'environment', environmentId: requestTarget.environmentId }, + 'github.prCheckDetails', + { + repo: requestTarget.runtimeRepoId, + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + { timeoutMs: 30_000, signal } + ) + ) + : await withGitHubCheckDetailsTimeout(() => + window.api.gh.prCheckDetails({ + repoPath, + repoId, + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null, + sourceContext: options?.sourceContext + }) + ) + } +}) diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts new file mode 100644 index 00000000000..09ba5103213 --- /dev/null +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -0,0 +1,93 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRComment } from '../../../../shared/github/comment-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { inflightCommentsRequests } from './request-coordination' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCommentFetchActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRComments: async (repoPath, prNumber, options): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const cached = get().commentsCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? [] + } + + const inflightRequest = inflightCommentsRequests.get(cacheKey) + if (inflightRequest) { + return inflightRequest + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const comments = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prComments', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + repoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force, + sourceContext: options?.sourceContext + })) as PRComment[]) + set((s) => ({ + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: comments, + fetchedAt: Date.now() + }) + })) + return comments + } catch (err) { + console.error('Failed to fetch PR comments:', err) + return get().commentsCache[cacheKey]?.data ?? [] + } finally { + inflightCommentsRequests.delete(cacheKey) + } + })() + + inflightCommentsRequests.set(cacheKey, request) + return request + } +}) diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts new file mode 100644 index 00000000000..5de7c86f75f --- /dev/null +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -0,0 +1,216 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubCommentResult, PRComment } from '../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { withBoundedCacheEntry } from './cache-policy' +import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCommentMutationActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let result: GitHubCommentResult + try { + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addPRReviewCommentReply', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addPRReviewCommentReply({ + repoPath, + repoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Failed to post reply.' + return { ok: false, error } + } + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result + } + const comment: PRComment = { + ...result.comment, + threadId: result.comment.threadId ?? options?.threadId, + path: result.comment.path ?? options?.path, + line: result.comment.line ?? options?.line + } + set((s) => { + const entry = s.commentsCache[cacheKey] + return { + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: mergePRCommentIntoList(entry?.data, comment), + fetchedAt: Date.now() + }) + } + }) + return { ok: true, comment } + }, + + setPRCommentReaction: async ( + repoPath, + prNumber, + reactionSubjectId, + content, + reacted, + options + ) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const previousComment = get().commentsCache[cacheKey]?.data?.find( + (comment) => comment.reactionSubjectId === reactionSubjectId + ) + const previousReaction = previousComment?.reactions?.find( + (reaction) => reaction.content === content + ) + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) + } + } + } + }) + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.setPRCommentReaction', + { + repo: requestContext.target.runtimeRepoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRCommentReaction({ + repoPath, + repoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update PR comment reaction:', err) + } + if (!ok && previousComment) { + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: restoreReactionOnSubject( + entry.data, + reactionSubjectId, + content, + previousReaction + ) + } + } + } + }) + } + return ok + } +}) diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts new file mode 100644 index 00000000000..d92a51baa39 --- /dev/null +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -0,0 +1,94 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubCommentResult } from '../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { withBoundedCacheEntry } from './cache-policy' +import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createConversationCommentActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + addPRConversationComment: async (repoPath, prNumber, body, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let result: GitHubCommentResult + try { + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addIssueComment', + { + repo: requestContext.target.runtimeRepoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addIssueComment({ + repoPath, + repoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Failed to post comment.' + return { ok: false, error } + } + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result + } + set((s) => { + const entry = s.commentsCache[cacheKey] + return { + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: mergePRCommentIntoList(entry?.data, result.comment), + fetchedAt: Date.now() + }) + } + }) + return result + } +}) diff --git a/src/renderer/src/store/github/issue-actions.ts b/src/renderer/src/store/github/issue-actions.ts new file mode 100644 index 00000000000..d360457a235 --- /dev/null +++ b/src/renderer/src/store/github/issue-actions.ts @@ -0,0 +1,94 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { sourceScopedRepoCacheKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { debouncedSaveCache } from './cache-persistence' +import { inflightIssueRequests } from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createIssueActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchIssue: async (repoPath, number, options) => { + const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + String(number), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const cached = get().issueCache[cacheKey] + if (isFresh(cached)) { + return cached.data + } + + const inflightRequest = inflightIssueRequests.get(cacheKey) + if (inflightRequest) { + return inflightRequest + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const issue = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.issue', + { repo: requestContext.target.runtimeRepoId, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ + repoPath, + repoId, + number, + sourceContext: options?.sourceContext + }) + set((s) => ({ + issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { + data: issue, + fetchedAt: Date.now() + }) + })) + debouncedSaveCache(get()) + return issue + } catch (err) { + console.error('Failed to fetch issue:', err) + set((s) => ({ + issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { + data: null, + fetchedAt: Date.now() + }) + })) + debouncedSaveCache(get()) + return null + } finally { + inflightIssueRequests.delete(cacheKey) + } + })() + + inflightIssueRequests.set(cacheKey, request) + return request + } +}) diff --git a/src/renderer/src/store/github/pr-check-status-cache.ts b/src/renderer/src/store/github/pr-check-status-cache.ts new file mode 100644 index 00000000000..0f1289aeb01 --- /dev/null +++ b/src/renderer/src/store/github/pr-check-status-cache.ts @@ -0,0 +1,82 @@ +import type { AppState } from '../types' +import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { deriveCheckStatusFromChecks } from '../slices/github-checks' +import { + getPRChecksCacheTtl, + prChecksCacheSuffix, + runtimeScopedRepoCacheKey +} from './cache-identity' + +export function applyCachedChecksStatus( + state: AppState, + alias: GitHubPRRefreshAlias, + pr: PRInfo, + fetchedAt: number, + aliasExecutionHostId: string +): PRInfo { + const checksCacheKeys = [ + ...(alias.repoId + ? [ + ...(pr.headSha + ? [ + runtimeScopedRepoCacheKey( + alias.repoPath, + alias.repoId, + prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + runtimeScopedRepoCacheKey( + alias.repoPath, + alias.repoId, + prChecksCacheSuffix(pr.number, pr.prRepo), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + ...(pr.headSha + ? [ + runtimeScopedRepoCacheKey( + alias.repoPath, + undefined, + prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + runtimeScopedRepoCacheKey( + alias.repoPath, + undefined, + prChecksCacheSuffix(pr.number, pr.prRepo), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ), + `${alias.repoPath}::pr-checks::${pr.number}` + ] + const checksEntry = checksCacheKeys + .map((key) => state.checksCache[key]) + .find((entry) => entry?.data) + if ( + checksEntry?.data && + checksEntry.headSha && + pr.headSha && + checksEntry.headSha === pr.headSha && + fetchedAt - checksEntry.fetchedAt < getPRChecksCacheTtl(checksEntry) + ) { + return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } + } + return pr +} diff --git a/src/renderer/src/store/github/pr-comment-cache.ts b/src/renderer/src/store/github/pr-comment-cache.ts new file mode 100644 index 00000000000..569a0f29be8 --- /dev/null +++ b/src/renderer/src/store/github/pr-comment-cache.ts @@ -0,0 +1,42 @@ +import type { GitHubCommentResult, PRComment } from '../../../../shared/github/comment-types' + +function commentTimestamp(comment: PRComment): number { + const timestamp = new Date(comment.createdAt).getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +export function mergePRCommentIntoList( + comments: readonly PRComment[] | null | undefined, + incoming: PRComment +): PRComment[] { + const byId = new Map() + for (const comment of comments ?? []) { + byId.set(comment.id, comment) + } + const previous = byId.get(incoming.id) + byId.set(incoming.id, { + ...previous, + ...incoming, + threadId: incoming.threadId ?? previous?.threadId, + path: incoming.path ?? previous?.path, + line: incoming.line ?? previous?.line, + startLine: incoming.startLine ?? previous?.startLine, + isResolved: incoming.isResolved ?? previous?.isResolved, + isOutdated: incoming.isOutdated ?? previous?.isOutdated + }) + return Array.from(byId.values()).sort((a, b) => commentTimestamp(a) - commentTimestamp(b)) +} + +export function hasUsableCommentPayload(result: GitHubCommentResult): result is { + ok: true + comment: PRComment +} { + return ( + result.ok && + typeof result.comment?.id === 'number' && + Number.isSafeInteger(result.comment.id) && + result.comment.id > 0 && + typeof result.comment.body === 'string' && + typeof result.comment.createdAt === 'string' + ) +} diff --git a/src/renderer/src/store/github/pr-refresh-state.ts b/src/renderer/src/store/github/pr-refresh-state.ts new file mode 100644 index 00000000000..13769332230 --- /dev/null +++ b/src/renderer/src/store/github/pr-refresh-state.ts @@ -0,0 +1,141 @@ +import type { + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshErrorType +} from '../../../../shared/github/pull-request-refresh-types' +import { capRecordByInsertionOrder } from './cache-policy' + +export type PRRefreshState = { + status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error' + reason: GitHubPRRefreshReason + updatedAt: number + pausedUntil?: number + message?: string + errorType?: PRRefreshErrorType + skippedReason?: GitHubPRRefreshSkippedReason + nextAutoRetryAt?: number + retryDisabledUntil?: number +} + +export type PRRefreshStateClearToken = { + sequence: number + status: PRRefreshState['status'] + updatedAt: number +} + +const PR_REFRESH_ACTIVE_STALE_MS = 120_000 +const PR_REFRESH_PAUSED_GRACE_MS = 5_000 +const MAX_PR_REFRESH_STATE_ENTRIES = 2000 +const SETTLED_PR_REFRESH_STATUSES = new Set(['error', 'skipped']) +export const ACTIVE_PR_REFRESH_STATUSES = new Set([ + 'queued', + 'in-flight', + 'paused' +]) + +export function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function buildGitHubPRRefreshStateClearToken( + state: PRRefreshState | undefined, + sequences: Record, + cacheKey: string +): PRRefreshStateClearToken | null { + if (!state) { + return null + } + return { + sequence: sequences[cacheKey] ?? 0, + status: state.status, + updatedAt: state.updatedAt + } +} + +export function getGitHubPRRefreshStateExpiryAt(state: PRRefreshState | undefined): number | null { + if (!state) { + return null + } + if (state.status === 'queued' || state.status === 'in-flight') { + return Number.isFinite(state.updatedAt) ? state.updatedAt + PR_REFRESH_ACTIVE_STALE_MS : 0 + } + if (state.status === 'paused') { + return Number.isFinite(state.pausedUntil) + ? (state.pausedUntil ?? 0) + PR_REFRESH_PAUSED_GRACE_MS + : 0 + } + return null +} + +export function isExpiredActivePRRefreshState(state: PRRefreshState, now: number): boolean { + const expiryAt = getGitHubPRRefreshStateExpiryAt(state) + return ACTIVE_PR_REFRESH_STATUSES.has(state.status) && expiryAt !== null && now > expiryAt +} + +export function getEffectiveGitHubPRRefreshState( + states: Record, + cacheKey: string, + now = Date.now() +): PRRefreshState | undefined { + const state = states[cacheKey] + return !state || isExpiredActivePRRefreshState(state, now) ? undefined : state +} + +export function pruneExpiredPRRefreshStates( + states: Record, + now = Date.now() +): Record { + let next: Record | null = null + for (const [cacheKey, state] of Object.entries(states)) { + if (!isExpiredActivePRRefreshState(state, now)) { + continue + } + next ??= { ...states } + delete next[cacheKey] + } + return next ?? states +} + +export function capPrRefreshStates( + states: Record, + maxEntries = MAX_PR_REFRESH_STATE_ENTRIES +): Record { + const keys = Object.keys(states) + let toEvict = keys.length - maxEntries + if (toEvict <= 0) { + return states + } + const evicted = new Set() + for (const key of keys) { + if (toEvict === 0) { + break + } + if (SETTLED_PR_REFRESH_STATUSES.has(states[key].status)) { + evicted.add(key) + toEvict -= 1 + } + } + for (const key of keys) { + if (toEvict === 0) { + break + } + if (!evicted.has(key)) { + evicted.add(key) + toEvict -= 1 + } + } + const capped: Record = {} + for (const key of keys) { + if (!evicted.has(key)) { + capped[key] = states[key] + } + } + return capped +} + +export function capPrRefreshSequences( + sequences: Record, + maxEntries?: number +): Record { + return capRecordByInsertionOrder(sequences, maxEntries) +} diff --git a/src/renderer/src/store/github/pr-result-cache.ts b/src/renderer/src/store/github/pr-result-cache.ts new file mode 100644 index 00000000000..7603d32332c --- /dev/null +++ b/src/renderer/src/store/github/pr-result-cache.ts @@ -0,0 +1,252 @@ +import type { AppState } from '../types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import type { GitHubPRFallbackSource } from './cache-model' +import { withBoundedCacheEntry } from './cache-policy' +import { prRefreshStartedHostedReviewEntries } from './request-coordination' +import { + shouldWritePRCacheForHostedReviewSync, + syncHostedReviewCacheFromGitHubPRResult +} from './pr-result-routing' +import { findWorktreeById } from './worktree-refresh' + +const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128 + +export function applyPRCacheResult( + cache: AppState['prCache'], + cacheKey: string, + pr: PRInfo | null, + fetchedAt: number, + accepted: boolean, + preserveExisting: boolean +): AppState['prCache'] { + if (preserveExisting) { + return cache + } + if (accepted) { + return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt }) + } + if (!cache[cacheKey]) { + return cache + } + const next = { ...cache } + delete next[cacheKey] + return next +} + +export function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string { + return `${sequence}::${cacheKey}` +} + +export function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void { + if (sequence !== undefined && sequence > 0) { + prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey)) + } +} + +export function setPRRefreshStartedHostedReviewEntry( + key: string, + entry: AppState['hostedReviewCache'][string] | undefined +): void { + if (entry === undefined) { + prRefreshStartedHostedReviewEntries.delete(key) + return + } + prRefreshStartedHostedReviewEntries.delete(key) + prRefreshStartedHostedReviewEntries.set(key, entry) + while (prRefreshStartedHostedReviewEntries.size > PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX) { + const oldest = prRefreshStartedHostedReviewEntries.keys().next() + if (oldest.done) { + return + } + prRefreshStartedHostedReviewEntries.delete(oldest.value) + } +} + +export function setGitHubPRResultCaches( + state: AppState, + args: { + prCacheKey: string + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] + } +): Partial { + const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ + currentPR: state.prCache[args.prCacheKey]?.data, + nextPR: args.pr, + state, + worktreeId: args.worktreeId, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource + }) + const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ + cache: state.hostedReviewCache, + repoPath: args.repoPath, + branch: args.branch, + settings: args.settings, + repoId: args.repoId, + connectionId: args.connectionId, + executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, + pr: args.pr, + fetchedAt: args.fetchedAt, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource, + preserveExistingPRForFallbackMiss, + requestStartedAt: args.requestStartedAt, + requestStartedEntry: args.requestStartedEntry + }) + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + const nextPRCache = applyPRCacheResult( + state.prCache, + args.prCacheKey, + args.pr, + args.fetchedAt, + shouldWritePRCacheForHostedReviewSync({ + hostedReviewSyncAccepted: hostedReviewSync.accepted, + hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey], + pr: args.pr, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber + }), + preserveExistingPRForFallbackMiss + ) + return { + ...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }), + ...(hostedReviewSync.cache === state.hostedReviewCache + ? {} + : { hostedReviewCache: hostedReviewSync.cache }) + } +} + +export function applyGitHubPRResultToCaches(args: { + prCache: AppState['prCache'] + hostedReviewCache: AppState['hostedReviewCache'] + prCacheKey: string + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + state: AppState + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] +}): { + prCache: AppState['prCache'] + hostedReviewCache: AppState['hostedReviewCache'] +} { + const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ + currentPR: args.prCache[args.prCacheKey]?.data, + nextPR: args.pr, + state: args.state, + worktreeId: args.worktreeId, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource + }) + const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ + cache: args.hostedReviewCache, + repoPath: args.repoPath, + branch: args.branch, + settings: args.settings, + repoId: args.repoId, + connectionId: args.connectionId, + executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, + pr: args.pr, + fetchedAt: args.fetchedAt, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource, + preserveExistingPRForFallbackMiss, + requestStartedAt: args.requestStartedAt, + requestStartedEntry: args.requestStartedEntry + }) + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + return { + prCache: applyPRCacheResult( + args.prCache, + args.prCacheKey, + args.pr, + args.fetchedAt, + shouldWritePRCacheForHostedReviewSync({ + hostedReviewSyncAccepted: hostedReviewSync.accepted, + hostedReviewEntry: args.hostedReviewCache[hostedReviewCacheKey], + pr: args.pr, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber + }), + preserveExistingPRForFallbackMiss + ), + hostedReviewCache: hostedReviewSync.cache + } +} +export function shouldPreserveExistingPRForFallbackMiss(args: { + currentPR: PRInfo | null | undefined + nextPR: PRInfo | null + state: AppState + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null +}): boolean { + if ( + args.nextPR !== null || + args.linkedPRNumber != null || + args.currentPR?.state !== 'merged' || + typeof args.currentPR.headSha !== 'string' || + args.currentPR.headSha.length === 0 + ) { + return false + } + // Why: gate the global worktree scan so batched refresh aliases don't multiply full scans (common paths don't need it). + const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null + const worktreeHead = worktree?.head + // Why: keep a merged PR only when its cached head matches the worktree head — exactly or a confirmed-contained commit. + const preservesMergedPRForCurrentHead = + typeof worktreeHead === 'string' && + worktreeHead.length > 0 && + (args.currentPR.headSha === worktreeHead || + args.currentPR.confirmedContainedHeadOid === worktreeHead) + + return preservesMergedPRForCurrentHead +} diff --git a/src/renderer/src/store/github/pr-result-routing.ts b/src/renderer/src/store/github/pr-result-routing.ts new file mode 100644 index 00000000000..8efd6e43b54 --- /dev/null +++ b/src/renderer/src/store/github/pr-result-routing.ts @@ -0,0 +1,196 @@ +import type { AppState } from '../types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' +import { + getHostedReviewCacheKey, + linkedReviewHintKey +} from '../slices/hosted-review-cache-identity' +import type { GitHubPRFallbackSource } from './cache-model' + +export function githubHostedReviewFallbackPRNumber( + state: AppState, + repoPath: string, + repoId: string | undefined, + branch: string, + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): number | null { + const hostedReviewCacheKey = getHostedReviewCacheKey( + repoPath, + branch, + state.settings, + repoId, + connectionId, + executionHostId, + hasRepoOwner + ) + const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data + return hostedReview?.provider === 'github' ? hostedReview.number : null +} + +export function shouldClearHostedReviewForNoGitHubPR( + entry: AppState['hostedReviewCache'][string] | undefined +): boolean { + // Why: a GitHub-only miss must not suppress GitLab/other hosted-review discovery via provider-neutral branch misses. + if (!entry) { + return false + } + if (entry.data?.provider === 'github') { + return true + } + return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) +} + +export function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { + return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false +} + +export function prLookupHintKey( + linkedPRNumber: number | null, + fallbackPRNumber: number | null +): string { + if (linkedPRNumber !== null) { + return `linked:${linkedPRNumber}` + } + return fallbackPRNumber !== null ? `fallback:${fallbackPRNumber}` : '' +} + +export function linkedReviewHintKeyForNoGitHubPR( + entry: AppState['hostedReviewCache'][string] | undefined +): string | undefined { + if (entry?.data?.provider === 'github') { + return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) + ? entry.linkedReviewHintKey + : linkedReviewHintKey({ linkedGitHubPR: entry.data.number }) + } + return entry?.linkedReviewHintKey +} + +export function hasNewerHostedReviewCacheEntry( + cache: AppState['hostedReviewCache'], + cacheKey: string, + requestStartedAt: number, + requestStartedEntry: AppState['hostedReviewCache'][string] | undefined +): boolean { + const entry = cache[cacheKey] + return ( + entry !== undefined && + (entry.fetchedAt > requestStartedAt || + (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) + ) +} + +export function syncHostedReviewCacheFromGitHubPRResult(args: { + cache: AppState['hostedReviewCache'] + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + preserveExistingPRForFallbackMiss?: boolean + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] +}): { cache: AppState['hostedReviewCache']; accepted: boolean } { + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + if ( + args.requestStartedAt !== undefined && + hasNewerHostedReviewCacheEntry( + args.cache, + hostedReviewCacheKey, + args.requestStartedAt, + args.requestStartedEntry + ) + ) { + return { cache: args.cache, accepted: false } + } + const hostedReviewEntry = args.cache[hostedReviewCacheKey] + if ( + args.requestStartedAt === undefined && + hostedReviewEntry !== undefined && + hostedReviewEntry.fetchedAt >= args.fetchedAt + ) { + return { cache: args.cache, accepted: false } + } + if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') { + return { cache: args.cache, accepted: false } + } + // Why: a hosted-review row survives an authoritative miss only when the paired PR cache preserves a terminal, head-current PR. + if ( + !args.pr && + args.linkedPRNumber == null && + args.fallbackPRNumber != null && + args.fallbackPRSource !== 'hosted-review' && + hostedReviewEntry?.data?.provider === 'github' && + hostedReviewEntry.data.number === args.fallbackPRNumber && + args.preserveExistingPRForFallbackMiss === true && + canPreserveReviewForFallbackMiss(hostedReviewEntry.data.state) + ) { + return { cache: args.cache, accepted: false } + } + if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) { + return { cache: args.cache, accepted: hostedReviewEntry?.data == null } + } + // Why: hosted-review fallbacks may be stale exact links; inherit branch provenance only when already proven. + const branchLookupGitHubPRNumber = + args.pr && + args.linkedPRNumber == null && + (args.fallbackPRSource !== 'hosted-review' || + args.pr.number !== args.fallbackPRNumber || + hostedReviewEntry?.branchLookupGitHubPRNumber === args.pr.number) + ? args.pr.number + : undefined + return { + cache: { + ...args.cache, + [hostedReviewCacheKey]: { + data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null, + fetchedAt: args.fetchedAt, + linkedReviewHintKey: args.pr + ? linkedReviewHintKey({ linkedGitHubPR: args.pr.number }) + : linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry), + ...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {}) + } + }, + accepted: true + } +} + +export function shouldWritePRCacheForHostedReviewSync(args: { + hostedReviewSyncAccepted: boolean + hostedReviewEntry: AppState['hostedReviewCache'][string] | undefined + pr: PRInfo | null + linkedPRNumber?: number | null + fallbackPRNumber?: number | null +}): boolean { + // Why: grouping reads prCache while cards read hostedReviewCache; keep them from drifting when a result is rejected for the card. + if (args.hostedReviewSyncAccepted) { + return true + } + const exactPRNumber = args.linkedPRNumber ?? args.fallbackPRNumber ?? null + return ( + exactPRNumber !== null && + args.pr?.number === exactPRNumber && + args.hostedReviewEntry?.data?.provider === 'github' && + args.hostedReviewEntry.data.number === exactPRNumber + ) +} + +function canPreserveReviewForFallbackMiss(state: PRInfo['state'] | undefined): boolean { + return state === 'closed' || state === 'merged' +} diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts new file mode 100644 index 00000000000..f4c5fec3100 --- /dev/null +++ b/src/renderer/src/store/github/project-actions.ts @@ -0,0 +1,254 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import { translate } from '@/i18n/i18n' +import type { + GetProjectViewTableResult, + GitHubProjectMutationResult +} from '../../../../shared/github/project-result-types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + projectViewCacheKey, + projectViewRequestKey, + projectViewSourceScope, + settingsForProjectViewCacheKey +} from './cache-identity' +import { withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightProjectViewRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { + applyRowPatch, + optimisticFieldValueFromMutation, + rollbackRowIfPresent +} from './project-cache' + +export const createProjectActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' +> => ({ + fetchProjectViewTable: async (args, options) => { + const target = getActiveRuntimeTarget(get().settings) + const sourceScope = projectViewSourceScope(get().settings) + const requestKey = projectViewRequestKey(args, sourceScope) + + // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. + const maybeKnownKey = args.viewId + ? projectViewCacheKey( + args.ownerType, + args.owner, + args.projectNumber, + args.viewId, + args.queryOverride, + sourceScope, + args.host + ) + : null + if (!options?.force && maybeKnownKey) { + const cached = get().projectViewCache[maybeKnownKey] + if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) { + return { ok: true, data: cached.data } + } + } + + const existing = inflightProjectViewRequests.get(requestKey) + if (existing) { + // Why: a forcing caller must not dedupe to a non-forcing in-flight request; wait for it to settle, then issue a fresh forced call (mirrors fetchWorkItems). + if (options?.force && !existing.force) { + await existing.promise.catch(() => {}) + } else { + return existing.promise + } + } + + const request = (async (): Promise => { + await acquireWorkItemSlot() + try { + const envelope = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.viewTable', + args, + { timeoutMs: 60_000 } + ) + : await window.api.gh.getProjectViewTable(args) + if (envelope.ok) { + const table = envelope.data + const key = projectViewCacheKey( + table.project.ownerType, + table.project.owner, + table.project.number, + table.selectedView.id, + args.queryOverride, + sourceScope, + table.project.host + ) + set((s) => ({ + projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { + data: table, + fetchedAt: Date.now() + }) + })) + } else if (maybeKnownKey) { + // Why: only stamp the error when we have a resolved key; without one there's nowhere to write it and the renderer classifies from the envelope. + set((s) => ({ + projectViewCache: withBoundedCacheEntry(s.projectViewCache, maybeKnownKey, { + data: s.projectViewCache[maybeKnownKey]?.data ?? null, + fetchedAt: Date.now(), + error: envelope.error + }) + })) + } + return envelope + } catch (err) { + // Why: the IPC boundary must not throw across the promise — wrap unexpected errors in the classified envelope for a single renderer shape. + console.error('Failed to fetch GitHub project view:', err) + return { + ok: false, + error: { + type: 'unknown', + message: err instanceof Error ? err.message : 'Failed to fetch project view' + } + } + } finally { + releaseWorkItemSlot() + inflightProjectViewRequests.delete(requestKey) + } + })() + + inflightProjectViewRequests.set(requestKey, { + promise: request, + force: Boolean(options?.force) + }) + return request + }, + + updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + // Optimistic patch: build a field value matching the mutation shape. + const nextField = optimisticFieldValueFromMutation(table, fieldId, value) + const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } + if (nextField) { + optimisticFieldValues[fieldId] = nextField + } + const optimisticRow: GitHubProjectRow = { + ...previousRow, + fieldValuesByFieldId: optimisticFieldValues + } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateItemField', + { + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId, + value + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateProjectItemField({ + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId, + value + }) + if (!result.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return result + }, + + clearProjectFieldValue: async (cacheKey, rowId, fieldId) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } + delete optimisticFieldValues[fieldId] + const optimisticRow: GitHubProjectRow = { + ...previousRow, + fieldValuesByFieldId: optimisticFieldValues + } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.clearItemField', + { + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.clearProjectItemField({ + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId + }) + if (!result.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return result + } +}) diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts new file mode 100644 index 00000000000..df8ac361025 --- /dev/null +++ b/src/renderer/src/store/github/project-cache.ts @@ -0,0 +1,129 @@ +import type { AppState } from '../types' +import type { + GitHubProjectFieldMutationValue, + GitHubProjectRow, + GitHubProjectTable +} from '../../../../shared/github/project-types' + +// Why: optimistic field value so the patched row re-renders immediately; best-effort, overwritten by the authoritative payload on next refresh. +export function optimisticFieldValueFromMutation( + table: GitHubProjectTable, + fieldId: string, + value: GitHubProjectFieldMutationValue +): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null { + const field = table.selectedView.fields.find((f) => f.id === fieldId) + switch (value.kind) { + case 'single-select': { + if (field?.kind === 'single-select') { + const option = field.options.find((o) => o.id === value.optionId) + if (option) { + return { + kind: 'single-select', + fieldId, + optionId: option.id, + name: option.name, + color: option.color + } + } + } + return { + kind: 'single-select', + fieldId, + optionId: value.optionId, + name: '', + color: '' + } + } + case 'iteration': { + if (field?.kind === 'iteration') { + const iteration = field.iterations.find((i) => i.id === value.iterationId) + if (iteration) { + return { + kind: 'iteration', + fieldId, + iterationId: iteration.id, + title: iteration.title, + startDate: iteration.startDate, + duration: iteration.duration + } + } + } + return { + kind: 'iteration', + fieldId, + iterationId: value.iterationId, + title: '', + startDate: '', + duration: 0 + } + } + case 'text': + return { kind: 'text', fieldId, text: value.text } + case 'number': + return { kind: 'number', fieldId, number: value.number } + case 'date': + return { kind: 'date', fieldId, date: value.date } + } + return null +} + +export function applyRowPatch( + set: (fn: (s: AppState) => Partial) => void, + cacheKey: string, + rowId: string, + nextRow: GitHubProjectRow +): void { + set((s) => { + const entry = s.projectViewCache[cacheKey] + if (!entry?.data) { + return {} + } + const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return {} + } + const rows = [...entry.data.rows] + rows[rowIndex] = nextRow + return { + projectViewCache: { + ...s.projectViewCache, + [cacheKey]: { + ...entry, + data: { ...entry.data, rows } + } + } + } + }) +} + +export function rollbackRowIfPresent( + set: (fn: (s: AppState) => Partial) => void, + get: () => AppState, + cacheKey: string, + rowId: string, + previousRow: GitHubProjectRow +): void { + // Why: skip rollback when the entry moved (rapid project switch) or the row is gone, else stale data would surface in the newly selected project. + const entry = get().projectViewCache[cacheKey] + if (!entry?.data) { + return + } + const stillPresent = entry.data.rows.some((r) => r.id === rowId) + if (!stillPresent) { + return + } + applyRowPatch(set, cacheKey, rowId, previousRow) +} + +export function parseSlugAndNumber( + row: GitHubProjectRow +): { owner: string; repo: string; number: number } | null { + if (!row.content.repository || row.content.number == null) { + return null + } + const parts = row.content.repository.split('/') + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null + } + return { owner: parts[0], repo: parts[1], number: row.content.number } +} diff --git a/src/renderer/src/store/github/project-row-actions.ts b/src/renderer/src/store/github/project-row-actions.ts new file mode 100644 index 00000000000..63fd0d18ee3 --- /dev/null +++ b/src/renderer/src/store/github/project-row-actions.ts @@ -0,0 +1,292 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { settingsForProjectRowOwner } from '../slices/github-project-row-owner' +import { settingsForProjectViewCacheKey } from './cache-identity' +import { applyRowPatch, parseSlugAndNumber, rollbackRowIfPresent } from './project-cache' + +export const createProjectRowActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + 'patchProjectIssueOrPr' | 'patchProjectRowIssueType' | 'patchProjectRowContent' +> => ({ + patchProjectIssueOrPr: async (cacheKey, rowId, updates) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {} + if (!owner || !repo || !number) { + return { + ok: false, + error: { + type: 'validation_error', + message: translate( + 'auto.store.slices.github.87020f6605', + 'Row has no owner/repo/number — cannot patch underlying item' + ) + } + } + } + // Optimistic content patch. + const nextContent = { ...previousRow.content } + if (updates.title !== undefined) { + nextContent.title = updates.title + } + if (updates.body !== undefined) { + nextContent.body = updates.body + } + if (updates.addLabels || updates.removeLabels) { + const next = new Map(nextContent.labels.map((l) => [l.name, l])) + for (const name of updates.addLabels ?? []) { + if (!next.has(name)) { + next.set(name, { name, color: '808080' }) + } + } + for (const name of updates.removeLabels ?? []) { + next.delete(name) + } + nextContent.labels = Array.from(next.values()) + } + if (updates.addAssignees || updates.removeAssignees) { + const next = new Map(nextContent.assignees.map((u) => [u.login, u])) + for (const login of updates.addAssignees ?? []) { + if (!next.has(login)) { + next.set(login, { login, name: null, avatarUrl: null }) + } + } + for (const login of updates.removeAssignees ?? []) { + next.delete(login) + } + nextContent.assignees = Array.from(next.values()) + } + const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + // Why: labels/assignees go through the issue endpoint for both (GitHub PRs are issues for those); title/body split PR→updatePullRequestBySlug vs issue→updateIssueBySlug. + let envelope: GitHubProjectMutationResult = { ok: true } + // Why: slug-only Project rows have no registered Orca repo, so fall back to the view source in the cache key, not the focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) + if ( + previousRow.itemType === 'PULL_REQUEST' && + (updates.title !== undefined || updates.body !== undefined) + ) { + const args = { + owner, + repo, + host: table.project.host, + number, + updates: { + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}) + } + } + const prRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updatePullRequestBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(args) + if (!prRes.ok) { + envelope = prRes + } + } + if ( + envelope.ok && + (updates.addLabels?.length || + updates.removeLabels?.length || + updates.addAssignees?.length || + updates.removeAssignees?.length || + (previousRow.itemType === 'ISSUE' && + (updates.title !== undefined || updates.body !== undefined))) + ) { + const args = { + owner, + repo, + host: table.project.host, + number, + updates: { + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.addLabels ? { addLabels: updates.addLabels } : {}), + ...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}), + ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), + ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) + } + } + const issueRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(args) + if (!issueRes.ok) { + envelope = issueRes + } + } + if (!envelope.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return envelope + }, + + patchProjectRowIssueType: async (cacheKey, rowId, issueType) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const row = table.rows.find((r) => r.id === rowId) + if (!row) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + if (row.itemType !== 'ISSUE') { + return { + ok: false, + error: { + type: 'validation_error', + message: translate( + 'auto.store.slices.github.83f9b126ad', + 'Issue Type can only be set on Issues.' + ) + } + } + } + const { owner, repo, number } = parseSlugAndNumber(row) ?? {} + if (!owner || !repo || !number) { + return { + ok: false, + error: { + type: 'validation_error', + message: translate('auto.store.slices.github.683a21264b', 'Row has no owner/repo/number.') + } + } + } + const previousRow = row + const optimistic: GitHubProjectRow = { + ...previousRow, + content: { ...previousRow.content, issueType } + } + applyRowPatch(set, cacheKey, rowId, optimistic) + // Why: slug-only Project rows belong to the host that loaded the view, which may differ from the now-focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) + const args = { + owner, + repo, + host: table.project.host, + number, + issueTypeId: issueType?.id ?? null + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueTypeBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueTypeBySlug(args) + if (!res.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return res + }, + + patchProjectRowContent: (cacheKey, rowId, patch) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return + } + const previousRow = table.rows.find((r) => r.id === rowId) + if (!previousRow) { + return + } + const nextContent = { ...previousRow.content } + if (patch.title !== undefined) { + nextContent.title = patch.title + } + if (patch.body !== undefined) { + nextContent.body = patch.body + } + if (patch.state !== undefined) { + // Why: ProjectV2 row.state is GitHub's UPPERCASE enum ('OPEN'|'CLOSED'|'MERGED') but the dialog tracks lowercase; upper-case so the patch matches the canonical row shape. + nextContent.state = patch.state.toUpperCase() + } + if (patch.labels !== undefined) { + const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l])) + nextContent.labels = patch.labels.map( + (name) => existingByName.get(name) ?? { name, color: '808080' } + ) + } + if (patch.assignees !== undefined) { + const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u])) + nextContent.assignees = patch.assignees.map( + (login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null } + ) + } + const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent } + applyRowPatch(set, cacheKey, rowId, nextRow) + } +}) diff --git a/src/renderer/src/store/github/pull-request-actions.ts b/src/renderer/src/store/github/pull-request-actions.ts new file mode 100644 index 00000000000..c9a43e075ac --- /dev/null +++ b/src/renderer/src/store/github/pull-request-actions.ts @@ -0,0 +1,155 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { GitHubPRFallbackSource } from './cache-model' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { prCacheKey } from './cache-identity' +import { isFresh } from './cache-policy' +import { buildGitHubPRRefreshStateClearToken } from './pr-refresh-state' +import { githubHostedReviewFallbackPRNumber, prLookupHintKey } from './pr-result-routing' +import { inflightPRRequests, prRequestGenerations } from './request-coordination' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { + findWorktreeById, + shouldApplyDivergedLinkedPRClear, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +import { startPullRequestLookup } from './pull-request-execution' + +export const createPullRequestActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRForBranch: async (repoPath, branch, options): Promise => { + const repoLookup = getGitHubRepoLookupIndex(get().repos) + const repo = options?.repoId + ? repoLookup.findById(options.repoId) + : repoLookup.findByPath(repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) + const cacheKey = prCacheKey( + repoPath, + repoId, + branch, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + const cached = get().prCache[cacheKey] + const hostedReviewCacheKey = getHostedReviewCacheKey( + repoPath, + branch, + requestSettings, + repoId, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + // Why: a prior linkedPR-less caller may have cached null for this branch; refetch so the cached miss can now resolve via the linkedPR path. + const linkedPRNumber = options?.linkedPRNumber ?? null + const explicitFallbackPRNumber = options?.fallbackPRNumber ?? null + const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( + get(), + repoPath, + repoId, + branch, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + const fallbackPRNumber = + linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null + const fallbackPRSource: GitHubPRFallbackSource | null = + linkedPRNumber != null || fallbackPRNumber == null + ? null + : (options?.fallbackPRSource ?? + (explicitFallbackPRNumber != null ? 'explicit' : 'hosted-review')) + const lookupHintKey = prLookupHintKey(linkedPRNumber, fallbackPRNumber) + const linkedRefetch = + cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null) + if (!options?.force && !linkedRefetch && isFresh(cached)) { + // Why: even a fresh cache hit carries the head-scoped divergence signal; if a prior clear was declined for a mid-request head move and we're back on that head, clear the durable link. + if ( + options?.worktreeId && + linkedPRNumber != null && + cached?.data?.headDivergedFromMergedPRAtOid != null + ) { + const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null + if ( + shouldClearDivergedLinkedMergedPR({ + pr: cached.data, + linkedPRNumber, + requestHeadOid: currentHeadOid + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: (worktree) => + shouldApplyDivergedLinkedPRClear({ + worktree, + linkedPRNumber, + branch, + requestHeadOid: currentHeadOid + }) + } + ) + } + } + return cached.data + } + + const inflightRequest = inflightPRRequests.get(cacheKey) + if ( + inflightRequest && + (!options?.force || inflightRequest.force) && + inflightRequest.lookupHintKey === lookupHintKey && + !linkedRefetch + ) { + return inflightRequest.promise + } + + const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1 + const requestStartedAt = Date.now() + const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey] + const requestStartedPRRefreshState = get().prRefreshStates[cacheKey] + const requestStartedPRRefreshToken = buildGitHubPRRefreshStateClearToken( + requestStartedPRRefreshState, + get().prRefreshSequences, + cacheKey + ) + prRequestGenerations.set(cacheKey, generation) + + const request = startPullRequestLookup({ + set, + get, + repoPath, + branch, + options, + repo, + repoId, + requestSettings, + cacheKey, + cached, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + generation, + requestStartedAt, + requestStartedHostedReviewEntry, + requestStartedPRRefreshToken + }) + + inflightPRRequests.set(cacheKey, { + promise: request, + force: Boolean(options?.force), + generation, + lookupHintKey + }) + return request + } +}) diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts new file mode 100644 index 00000000000..3233fc2f40b --- /dev/null +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -0,0 +1,286 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { Repo } from '../../../../shared/repo-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { CacheEntry, GitHubPRFallbackSource } from './cache-model' +import type { PRRefreshStateClearToken } from './pr-refresh-state' + +import type { + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../../../shared/github/pull-request-refresh-types' +import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { debouncedSaveCache } from './cache-persistence' +import { inflightPRRequests, prRequestGenerations } from './request-coordination' +import { getRuntimeRepoTarget } from './repository-routing' +import { setGitHubPRResultCaches, shouldPreserveExistingPRForFallbackMiss } from './pr-result-cache' +import { + findUniqueWorktreeById, + findWorktreeById, + isStaleExactLinkedPRLookup, + shouldApplyBranchMismatchedLinkedPRClear, + shouldApplyDivergedLinkedPRClear, + shouldClearBranchMismatchedLinkedOpenPR, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +export function startPullRequestLookup(args: { + set: Parameters>[0] + get: Parameters>[1] + repoPath: string + branch: string + options: Parameters[2] + repo: Repo | undefined + repoId: string | undefined + requestSettings: AppState['settings'] + cacheKey: string + cached: CacheEntry | undefined + linkedPRNumber: number | null + fallbackPRNumber: number | null + fallbackPRSource: GitHubPRFallbackSource | null + generation: number + requestStartedAt: number + requestStartedHostedReviewEntry: AppState['hostedReviewCache'][string] | undefined + requestStartedPRRefreshToken: PRRefreshStateClearToken | null +}): Promise { + const { + set, + get, + repoPath, + branch, + options, + repo, + repoId, + requestSettings, + cacheKey, + cached, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + generation, + requestStartedAt, + requestStartedHostedReviewEntry, + requestStartedPRRefreshToken + } = args + const request = (async () => { + try { + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const candidateWorktree = options?.worktreeId + ? findWorktreeById(get(), options.worktreeId) + : null + const requestHeadOid = candidateWorktree?.head ?? null + const outcome = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prForBranch', + { + repo: runtimeRepo.repo.id, + branch, + linkedPRNumber, + currentHeadOid: requestHeadOid, + ...(fallbackPRNumber !== null + ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } + : {}) + }, + { timeoutMs: 30_000 } + ).then((result) => normalizeGitHubPRForBranchOutcome(result)) + : await (async () => { + const candidate: GitHubPRRefreshCandidate = { + repoId: repoId ?? '', + repoPath, + repoKind: repo?.kind ?? 'git', + branch, + cacheKey, + worktreeId: options?.worktreeId, + currentHeadOid: requestHeadOid, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + connectionId: repo?.connectionId ?? null, + executionHostId: repo?.executionHostId ?? null, + cachedFetchedAt: cached?.fetchedAt ?? null, + cachedHasPR: cached?.data ? true : cached ? false : null, + cachedPRState: cached?.data?.state ?? null, + cachedChecksStatus: cached?.data?.checksStatus ?? null, + cachedMergeable: cached?.data?.mergeable ?? null, + cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null + } + const response = window.api.gh.refreshPRNow + ? await window.api.gh.refreshPRNow({ candidate }) + : await window.api.gh.prForBranch({ + repoPath, + repoId, + branch, + linkedPRNumber, + fallbackPRNumber, + acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null, + currentHeadOid: requestHeadOid + }) + return normalizeGitHubPRForBranchOutcome(response) + })() + const pr: PRInfo | null = + outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null + if (outcome.kind === 'upstream-error') { + // Why: the runtime RPC path skips the coordinator broadcast that fills prRefreshStates on native, so record the classified error here for Checks parity with native (design criterion 2). + if (runtimeRepo && prRequestGenerations.get(cacheKey) === generation) { + set((s) => { + const nextStates = { ...s.prRefreshStates } + delete nextStates[cacheKey] + nextStates[cacheKey] = { + status: 'error', + reason: 'swr', + updatedAt: Date.now(), + message: outcome.message, + errorType: outcome.errorType, + nextAutoRetryAt: outcome.nextAutoRetryAt, + retryDisabledUntil: outcome.retryDisabledUntil + } + return { prRefreshStates: nextStates } + }) + } + return cached?.data ?? null + } + if (prRequestGenerations.get(cacheKey) === generation) { + let skippedStaleLinkedPRLookup = false + let didUpdatePRCache = false + set((s) => { + // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. + if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { + skippedStaleLinkedPRLookup = true + return {} + } + const updates = setGitHubPRResultCaches(s, { + prCacheKey: cacheKey, + repoPath, + branch, + settings: requestSettings, + repoId, + connectionId: repo?.connectionId, + executionHostId: repo?.executionHostId, + hasRepoOwner: repo !== undefined, + pr, + fetchedAt: outcome.fetchedAt, + worktreeId: options?.worktreeId, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + requestStartedAt, + requestStartedEntry: requestStartedHostedReviewEntry + }) + didUpdatePRCache = updates.prCache !== undefined + return updates + }) + if (skippedStaleLinkedPRLookup) { + return null + } + if (didUpdatePRCache) { + debouncedSaveCache(get()) + } + const linkedPRWorktree = + options?.worktreeId && linkedPRNumber != null + ? findUniqueWorktreeById( + get(), + options.worktreeId, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) + : null + if ( + options?.worktreeId && + linkedPRWorktree && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) + ) { + // Why: only clear the durable link that produced this exact probe; drift means the stale result no longer owns the worktree. + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyDivergedLinkedPRClear({ + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + } + if ( + options?.worktreeId && + linkedPRWorktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr, + linkedPRNumber, + branch, + requestHeadOid, + pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + // Why: the branch-scoped PR refetch below updates both caches; the generic metadata refresh would duplicate provider work. + suppressHostedReviewRefresh: true, + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + // Re-resolve by branch now so Checks recover this refresh instead of serving the stale linked PR. + void get().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId: options.worktreeId + }) + } + } + if ( + shouldPreserveExistingPRForFallbackMiss({ + currentPR: get().prCache[cacheKey]?.data, + nextPR: pr, + state: get(), + worktreeId: options?.worktreeId, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource + }) + ) { + return get().prCache[cacheKey]?.data ?? null + } + return pr ?? null + } catch (err) { + console.error('Failed to fetch PR:', err) + return null + } finally { + const activeRequest = inflightPRRequests.get(cacheKey) + if (activeRequest?.generation === generation) { + inflightPRRequests.delete(cacheKey) + if (prRequestGenerations.get(cacheKey) === generation) { + prRequestGenerations.delete(cacheKey) + } + } + if (requestStartedPRRefreshToken) { + get().expireGitHubPRRefreshState(cacheKey, requestStartedPRRefreshToken) + } + } + })() + return request +} diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts new file mode 100644 index 00000000000..1e4bccbebd6 --- /dev/null +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -0,0 +1,278 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import { applyCachedChecksStatus } from './pr-check-status-cache' +import { debouncedSaveCache } from './cache-persistence' +import { + capPrRefreshSequences, + capPrRefreshStates, + pruneExpiredPRRefreshStates +} from './pr-refresh-state' +import { + applyGitHubPRResultToCaches, + deletePRRefreshStartedEntry, + prRefreshStartedEntryKey, + setPRRefreshStartedHostedReviewEntry +} from './pr-result-cache' +import { prRefreshStartedHostedReviewEntries } from './request-coordination' +import { getRefreshAliasExecutionHostId } from './repository-routing' +import { + buildWorktreeLookupIndex, + findUniqueWorktreeById, + isStaleExactLinkedPRLookup, + shouldApplyBranchMismatchedLinkedPRClear, + shouldApplyDivergedLinkedPRClear, + shouldClearBranchMismatchedLinkedOpenPR, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +import type { WorktreeLookupIndex } from './worktree-refresh' + +export const createRefreshEventActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + applyGitHubPRRefreshEvent: (event) => { + // Why: local-repo sidebar refresh routes through the main PR coordinator, so run the same guarded diverged-merged-PR clear. + const divergedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + executionHostId: string + }[] = [] + const branchMismatchedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + executionHostId: string + }[] = [] + let didUpdatePRCache = false + set((s) => { + let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined + const nextSequences = { ...s.prRefreshSequences } + const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates) + const nextStates = { ...prunedStates } + let nextPRCache = s.prCache + let nextHostedReviewCache = s.hostedReviewCache ?? {} + let changed = prunedStates !== s.prRefreshStates + + for (const alias of event.aliases) { + const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) + const previousSequence = nextSequences[alias.cacheKey] ?? 0 + if ( + event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence + ) { + if (event.outcome || event.status !== 'in-flight') { + deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) + } + continue + } + // Why: delete-then-set re-orders this key last so capPrRefreshSequences evicts idle, not active, keys. + delete nextSequences[alias.cacheKey] + nextSequences[alias.cacheKey] = event.sequence + changed = true + + if (event.outcome) { + const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey) + const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey) + prRefreshStartedHostedReviewEntries.delete(startedEntryKey) + if (previousSequence !== event.sequence) { + deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) + } + delete nextStates[alias.cacheKey] + if (event.outcome.kind === 'upstream-error') { + nextStates[alias.cacheKey] = { + status: 'error', + reason: event.reason, + updatedAt: Date.now(), + message: event.outcome.message, + errorType: event.outcome.errorType, + nextAutoRetryAt: event.outcome.nextAutoRetryAt, + retryDisabledUntil: event.outcome.retryDisabledUntil + } + continue + } + const data = + event.outcome.kind === 'found' + ? applyCachedChecksStatus( + s, + alias, + event.outcome.pr, + event.outcome.fetchedAt, + aliasExecutionHostId + ) + : null + const linkedPRNumber = alias.linkedPRNumber ?? null + // Why: one outcome fans out to many aliases; build one lazy index instead of rescanning worktrees per alias. + const worktreeLookupIndex = + alias.worktreeId && linkedPRNumber != null + ? (linkedWorktreeLookupIndex ??= buildWorktreeLookupIndex(s)) + : undefined + // Why: a queued refresh finishing after the user unlinks an exact PR must not restore the manual-link UI. + if ( + isStaleExactLinkedPRLookup(s, alias.worktreeId, linkedPRNumber, worktreeLookupIndex) + ) { + continue + } + if (event.outcome.kind === 'found' && alias.worktreeId) { + const requestHeadOid = alias.currentHeadOid ?? null + const worktree = + linkedPRNumber != null + ? findUniqueWorktreeById( + s, + alias.worktreeId, + aliasExecutionHostId, + worktreeLookupIndex + ) + : null + // Why: only the sequence-gate winner owns metadata side effects; late outcomes must not unlink a newer PR. + if ( + worktree && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ + pr: event.outcome.pr, + linkedPRNumber, + requestHeadOid + }) + ) { + divergedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } else if ( + worktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr: event.outcome.pr, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + pushTargetBranch: worktree.pushTarget?.branchName ?? null + }) + ) { + branchMismatchedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } + } + const nextCaches = applyGitHubPRResultToCaches({ + prCache: nextPRCache, + hostedReviewCache: nextHostedReviewCache, + prCacheKey: alias.cacheKey, + repoPath: alias.repoPath, + branch: alias.branch, + settings: s.settings, + repoId: alias.repoId, + connectionId: alias.connectionId, + executionHostId: aliasExecutionHostId, + hasRepoOwner: true, + pr: data, + fetchedAt: event.outcome.fetchedAt, + state: s, + worktreeId: alias.worktreeId, + linkedPRNumber: alias.linkedPRNumber, + fallbackPRNumber: alias.fallbackPRNumber, + fallbackPRSource: alias.fallbackPRSource, + requestStartedAt: event.requestStartedAt, + requestStartedEntry + }) + didUpdatePRCache = didUpdatePRCache || nextCaches.prCache !== nextPRCache + nextPRCache = nextCaches.prCache + nextHostedReviewCache = nextCaches.hostedReviewCache + continue + } + + if (event.status) { + if (previousSequence !== event.sequence) { + deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) + } + if (event.status === 'in-flight' && event.requestStartedAt !== undefined) { + const hostedReviewCacheKey = getHostedReviewCacheKey( + alias.repoPath, + alias.branch, + s.settings, + alias.repoId, + alias.connectionId, + aliasExecutionHostId, + true + ) + setPRRefreshStartedHostedReviewEntry( + prRefreshStartedEntryKey(event.sequence, alias.cacheKey), + s.hostedReviewCache[hostedReviewCacheKey] + ) + } else { + // Why: pause/skip can follow an in-flight broadcast with no outcome; drop the stale request-start snapshot. + deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) + } + // Why: delete-then-set re-orders this key last so capRecordByInsertionOrder evicts idle, not active, keys. + delete nextStates[alias.cacheKey] + const isPaused = event.status === 'paused' + nextStates[alias.cacheKey] = { + status: event.status, + reason: event.reason, + updatedAt: Date.now(), + pausedUntil: event.pausedUntil, + skippedReason: event.skippedReason, + // Why: paused = rate-limit gate; map pausedUntil into the schedule to show auto-retry and disable manual Retry. + nextAutoRetryAt: isPaused ? event.pausedUntil : undefined, + retryDisabledUntil: isPaused ? event.pausedUntil : undefined + } + } + } + + return changed + ? { + prRefreshSequences: capPrRefreshSequences(nextSequences), + // Why: bound prRefreshStates with status-aware eviction so visible in-progress pills survive. + prRefreshStates: capPrRefreshStates(nextStates), + prCache: nextPRCache, + hostedReviewCache: nextHostedReviewCache + } + : {} + }) + if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { + debouncedSaveCache(get()) + } + for (const clear of divergedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyDivergedLinkedPRClear({ + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } + for (const clear of branchMismatchedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } + } +}) diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts new file mode 100644 index 00000000000..bca0e2020d2 --- /dev/null +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -0,0 +1,90 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import { bypassesGitHubPRRefreshFreshness } from './pr-refresh-state' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { buildPRRefreshCandidate, findWorktreeById } from './worktree-refresh' + +export const createRefreshRoutingActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'enqueueGitHubPRRefresh' + | 'reportVisibleGitHubPRRefreshCandidates' + | 'bumpGitHubPRVisibleRefreshGeneration' +> => ({ + enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { + const state = get() + const worktree = findWorktreeById(state, worktreeId) + const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null + if (!candidate) { + return + } + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: bypassesGitHubPRRefreshFreshness(reason), + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + return + } + if (!shouldEnqueueLocalPRRefresh(candidate)) { + return + } + enqueueLocalGitHubPRRefresh({ candidate, reason, priority }, async () => { + await get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: bypassesGitHubPRRefreshFreshness(reason), + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + }) + }, + + reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { + const state = get() + const candidates = worktreeIds + .map((id) => { + const worktree = findWorktreeById(state, id) + return worktree ? buildPRRefreshCandidate(state, worktree) : null + }) + .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) + const localCandidates: GitHubPRRefreshCandidate[] = [] + for (const candidate of candidates) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + continue + } + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) + } + } + const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates + if (reportVisible) { + void reportVisible({ candidates: localCandidates, generation }).catch((err) => { + console.warn('Failed to report visible PR refresh candidates:', err) + }) + } + }, + + bumpGitHubPRVisibleRefreshGeneration: () => { + set((s) => ({ prVisibleRefreshGeneration: s.prVisibleRefreshGeneration + 1 })) + } +}) diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts new file mode 100644 index 00000000000..ab28629539d --- /dev/null +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -0,0 +1,221 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { issueCacheKey, prCacheKey } from './cache-identity' +import { CACHE_TTL, evictStaleEntries } from './cache-policy' +import { pruneExpiredPRRefreshStates } from './pr-refresh-state' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + getRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { buildPRRefreshCandidate } from './worktree-refresh' + +export const createRefreshSweepActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + refreshAllGitHub: () => { + // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. + set((s) => { + const next = { + commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, + prCache: evictStaleEntries(s.prCache), + issueCache: evictStaleEntries(s.issueCache), + checksCache: evictStaleEntries(s.checksCache), + workItemsCache: evictStaleEntries(s.workItemsCache), + projectViewCache: evictStaleEntries(s.projectViewCache), + prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) + } + // Why: each eviction helper returns its input untouched when nothing changed, so an + // unchanged sweep can return `s` and avoid waking every subscriber on window resume. + return next.commentsCache === s.commentsCache && + next.prCache === s.prCache && + next.issueCache === s.issueCache && + next.checksCache === s.checksCache && + next.workItemsCache === s.workItemsCache && + next.projectViewCache === s.projectViewCache && + next.prRefreshStates === s.prRefreshStates + ? s + : next + }) + + // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. + + // Only re-fetch PR/issue entries that are already stale — skip fresh ones + const state = get() + const cardProps = state.worktreeCardProperties ?? [] + const rawCardProps = cardProps as readonly string[] + const shouldRefreshIssues = (state.worktreeCardProperties ?? []).includes('issue') + const isPRStatusGrouping = state.groupBy === 'pr-status' + const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state) + const shouldRefreshPRs = + isPRStatusGrouping || + rightSidebarShowsPR || + (state.settings?.experimentalNewWorktreeCardStyle === true + ? cardProps.includes('status') + : cardProps.includes('pr') || rawCardProps.includes('ci')) + if (!shouldRefreshPRs && !shouldRefreshIssues) { + return + } + + const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const repoLookup = getGitHubRepoLookupIndex(state.repos) + + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const wt of worktrees) { + const repo = repoLookup.findById(wt.repoId) + if (!repo) { + continue + } + + const branch = wt.branch.replace(/^refs\/heads\//, '') + if (shouldRefreshPRs && !wt.isBare && branch) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) + const prEntry = state.prCache[prKey] + if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { + const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) + if (candidate) { + stalePRCandidates.push({ + candidate, + score: + (state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) + + wt.lastActivityAt + }) + } + } + } + if (shouldRefreshIssues && wt.linkedIssue) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + wt.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + const issueEntry = state.issueCache[issueKey] + if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { + void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) + } + } + } + } + const candidatesToRefresh = stalePRCandidates + .sort((a, b) => b.score - a.score) + .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) + for (const { candidate } of candidatesToRefresh) { + const candidateSettings = settingsForGitHubRepoOwner( + state.settings, + candidate as Pick + ) + if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) + } + } + }, + + refreshGitHubForWorktree: (worktreeId) => { + const state = get() + let worktree: Worktree | undefined + for (const worktrees of Object.values(state.worktreesByRepo)) { + worktree = worktrees.find((w) => w.id === worktreeId) + if (worktree) { + break + } + } + if (!worktree) { + return + } + + const repo = state.repos.find((r) => r.id === worktree.repoId) + if (!repo) { + return + } + + // Invalidate this worktree's cache entries + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) + const issueKey = worktree.linkedIssue + ? issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + : '' + + set((s) => { + const updates: Partial = {} + if (s.prCache[prKey]) { + updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } } + } + if (issueKey && s.issueCache[issueKey]) { + updates.issueCache = { + ...s.issueCache, + [issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 } + } + } + return updates + }) + + // Re-fetch (skip when branch is empty — detached HEAD during rebase) + if (!worktree.isBare && branch) { + const candidate = buildPRRefreshCandidate(get(), worktree) + if (candidate) { + if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: true, + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) + } + } + } + if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) { + void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + } + } +}) diff --git a/src/renderer/src/store/github/repository-routing.ts b/src/renderer/src/store/github/repository-routing.ts new file mode 100644 index 00000000000..95967b07979 --- /dev/null +++ b/src/renderer/src/store/github/repository-routing.ts @@ -0,0 +1,117 @@ +import type { AppState } from '../types' +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../../../shared/repo-types' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId +} from '../../../../shared/execution-host' + +export function getRuntimeRepoTarget( + state: AppState, + repoPath: string, + settings: AppState['settings'] = state.settings +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return null + } + const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) + return repo ? { target, repo } : null +} + +export function getPRRefreshOwnerRuntimeEnvironmentId( + candidate: Pick +): string | null { + const parsed = parseExecutionHostId(candidate.executionHostId) + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + const cacheScope = candidate.cacheKey.split('::', 1)[0] + const cacheScopeHost = parseExecutionHostId(cacheScope) + return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null +} + +export function getPRRefreshRuntimeRepoTarget( + state: AppState, + candidate: GitHubPRRefreshCandidate +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + if (!ownerRuntimeEnvironmentId) { + return null + } + // Why: PR refreshes must follow the repo owner host, not the Active Server dropdown (a runtime-owned worktree can show while Local is focused). + return getRuntimeRepoTarget( + state, + candidate.repoPath, + state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + ) +} + +export function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { + // Why: the local coordinator owns local git + SSH-bridge refreshes; runtime-owned and disconnected-SSH repos must not hit the IPC crash path. + if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { + return false + } + return !candidate.connectionId || candidate.connectionState === 'connected' +} + +export function enqueueLocalGitHubPRRefresh( + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority: number + }, + onNotQueued?: () => void | Promise +): void { + const enqueue = window.api.gh.enqueuePRRefresh + if (!enqueue) { + return + } + // Why: renderer refresh triggers are best-effort — main may reject stale paths, and this must not become an unhandled-rejection crash. + void enqueue(args) + .then((queued) => + queued === false || queued?.kind === 'fallback' ? onNotQueued?.() : undefined + ) + .catch((err) => { + console.warn('Failed to enqueue PR refresh:', err) + }) +} +export function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { + const explicitHostId = normalizeExecutionHostId(alias.executionHostId) + if (explicitHostId) { + return explicitHostId + } + const scope = alias.cacheKey.split('::', 1)[0] + return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID +} + +export function findRepoForGitHubOwner( + state: Partial>, + repoId: string | undefined, + repoPath: string +): Repo | undefined { + return state.repos + ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) + : undefined +} + +export function getGitHubFocusedRepoOwnerHostId( + settings: AppState['settings'], + repo: Pick | undefined +): string { + if (repo?.executionHostId || repo?.connectionId) { + return getRepoExecutionHostId(repo) + } + return getSettingsFocusedExecutionHostId(settings) +} diff --git a/src/renderer/src/store/github/request-coordination.ts b/src/renderer/src/store/github/request-coordination.ts new file mode 100644 index 00000000000..fc6f3cb0b7c --- /dev/null +++ b/src/renderer/src/store/github/request-coordination.ts @@ -0,0 +1,84 @@ +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { IssueInfo, PRInfo } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import type { AppState } from '../types' + +export type InflightPR = { + promise: Promise + force: boolean + generation: number + lookupHintKey: string +} +export type InflightChecks = { + promise: Promise + force: boolean + noCache: boolean +} +export type InflightWorkItems = { + promise: Promise + force: boolean + noCache: boolean + requireComplete: boolean +} + +export const inflightPRRequests = new Map() +export const inflightIssueRequests = new Map>() +export const inflightChecksRequests = new Map() +export const inflightCommentsRequests = new Map>() +export const inflightWorkItemsRequests = new Map() +export const inflightProjectViewRequests = new Map< + string, + { promise: Promise; force: boolean } +>() +export const prRequestGenerations = new Map() +export const prRefreshStartedHostedReviewEntries = new Map< + string, + AppState['hostedReviewCache'][string] | undefined +>() + +export function _getGitHubPRRequestGenerationCountForTest(): number { + return prRequestGenerations.size +} + +export function _getGitHubPRRefreshStartedEntryCountForTest(): number { + return prRefreshStartedHostedReviewEntries.size +} + +export function _clearGitHubPRRefreshStartedEntriesForTest(): void { + prRefreshStartedHostedReviewEntries.clear() +} + +const PROVIDER_REQUEST_CONCURRENCY = 8 +let providerRequestsInFlight = 0 +const providerRequestWaiters: (() => void)[] = [] + +export async function acquireProviderRequestSlot(): Promise { + if (providerRequestsInFlight < PROVIDER_REQUEST_CONCURRENCY) { + providerRequestsInFlight += 1 + return + } + await new Promise((resolve) => providerRequestWaiters.push(resolve)) +} + +export function releaseProviderRequestSlot(): void { + const next = providerRequestWaiters.shift() + if (next) { + next() + return + } + providerRequestsInFlight -= 1 +} + +export function clearInflightWorkItemsForRepo(repoId: string, repoPath?: string): void { + const prefixes = [`${repoId}::`] + if (repoPath && repoPath !== repoId) { + prefixes.push(`${repoPath}::`) + } + for (const key of Array.from(inflightWorkItemsRequests.keys())) { + if (prefixes.some((prefix) => key.startsWith(prefix))) { + inflightWorkItemsRequests.delete(key) + } + } +} diff --git a/src/renderer/src/store/github/review-thread-actions.ts b/src/renderer/src/store/github/review-thread-actions.ts new file mode 100644 index 00000000000..727465919e2 --- /dev/null +++ b/src/renderer/src/store/github/review-thread-actions.ts @@ -0,0 +1,92 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createReviewThreadActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + + // Optimistic toggle of isResolved for this thread; reverts if the API call fails. + const prev = get().commentsCache[cacheKey]?.data + if (prev) { + set((s) => ({ + commentsCache: { + ...s.commentsCache, + [cacheKey]: { + ...s.commentsCache[cacheKey], + data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c)) + } + } + })) + } + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.resolveReviewThread', + { + repo: requestContext.target.runtimeRepoId, + threadId, + resolve, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ + repoPath, + repoId, + threadId, + resolve, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update review thread:', err) + ok = false + } + if (!ok && prev) { + // Revert optimistic update on failure + set((s) => ({ + commentsCache: { + ...s.commentsCache, + [cacheKey]: { ...s.commentsCache[cacheKey], data: prev } + } + })) + } + return ok + } +}) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts new file mode 100644 index 00000000000..0d1f41c5da6 --- /dev/null +++ b/src/renderer/src/store/github/slice-types.ts @@ -0,0 +1,276 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { + GitHubCommentResult, + GitHubReactionContent, + PRComment +} from '../../../../shared/github/comment-types' +import type { + GitHubPRRefreshEvent, + GitHubPRRefreshReason +} from '../../../../shared/github/pull-request-refresh-types' +import type { + GitHubOwnerRepo, + IssueInfo, + PRInfo +} from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import type { + GitHubProjectFieldMutationValue, + GitHubProjectTable +} from '../../../../shared/github/project-types' +import type { + GetProjectViewTableResult, + GitHubProjectMutationResult +} from '../../../../shared/github/project-result-types' +import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { + CacheEntry, + FetchOptions, + GitHubPatchWorkItemOptions, + GitHubPRFallbackSource, + ProjectRowContentPatch, + ProjectRowContentUpdate, + ProjectViewCacheEntry, + RepoScopedFetchOptions, + WorkItemsCacheError, + WorkItemsCacheSources +} from './cache-model' +import type { PRRefreshState, PRRefreshStateClearToken } from './pr-refresh-state' + +export type GitHubSlice = { + prCache: Record> + issueCache: Record> + checksCache: Record> + commentsCache: Record> + prRefreshSequences: Record + prRefreshStates: Record + prVisibleRefreshGeneration: number + // Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results. + workItemsCache: Record> + fetchPRForBranch: ( + repoPath: string, + branch: string, + options?: RepoScopedFetchOptions & { + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + } + ) => Promise + fetchIssue: ( + repoPath: string, + number: number, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRChecks: ( + repoPath: string, + prNumber: number, + branch?: string, + headSha?: string, + prRepo?: GitHubOwnerRepo | null, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRCheckDetails: ( + repoPath: string, + args: { + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRComments: ( + repoPath: string, + prNumber: number, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + addPRConversationComment: ( + repoPath: string, + prNumber: number, + body: string, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + addPRReviewCommentReply: ( + repoPath: string, + prNumber: number, + commentId: number, + body: string, + options?: RepoScopedFetchOptions & { + prRepo?: GitHubOwnerRepo | null + threadId?: string + path?: string + line?: number + } + ) => Promise + setPRCommentReaction: ( + repoPath: string, + prNumber: number, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + resolveReviewThread: ( + repoPath: string, + prNumber: number, + threadId: string, + resolve: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + initGitHubCache: () => Promise + refreshAllGitHub: () => void + refreshGitHubForWorktree: (worktreeId: string) => void + refreshGitHubForWorktreeIfStale: (worktreeId: string) => void + enqueueGitHubPRRefresh: ( + worktreeId: string, + reason: GitHubPRRefreshReason, + priority?: number + ) => void + reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void + bumpGitHubPRVisibleRefreshGeneration: () => void + applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void + getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined + expireGitHubPRRefreshState: ( + cacheKey: string, + token: PRRefreshStateClearToken, + now?: number + ) => void + /** SWR: returns cached work items immediately (null if none) and fires a background refresh when stale. */ + getCachedWorkItems: ( + repoId: string, + limit: number, + query: string, + repoPath?: string, + sourceContext?: TaskSourceContext | null + ) => readonly GitHubWorkItem[] | null + /** Returns a thin view (sources + error, never items) so it stays a cheap selector without dragging the whole work-item array through the equality check. */ + getWorkItemsSourcesAndError: ( + repoId: string, + limit: number, + query: string, + repoPath?: string + ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } + /** + * Falls back to any `${repoPath}::` cache entry with resolved sources when the primary entry isn't populated yet — sources are repo-level (query-independent), so any sibling is safe to reuse. + * Returns a single stable reference so the dialog can subscribe to just this selector; entries are replaced (not mutated) on write, preserving reference equality between unchanged entries. + */ + getWorkItemsAnySourcesForRepo: ( + repoId: string, + limit: number, + repoPath?: string + ) => WorkItemsCacheSources | null + fetchWorkItems: ( + repoId: string, + repoPath: string, + limit: number, + query: string, + options?: FetchOptions + ) => Promise + /** + * Fan out one work-item query across repos; partial failures don't reject — a repo with no cached fallback increments `failedCount`, but one served stale cache on rejection isn't counted. + * `githubUnavailable`: every selected GitHub source refresh failed because GitHub was unreachable (5xx/network/rate-limit), even if stale cache remains — lets the caller attribute the stale/empty list. + */ + fetchWorkItemsAcrossRepos: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + perRepoLimit: number, + displayLimit: number, + query: string, + options?: FetchOptions + ) => Promise<{ + items: GitHubWorkItem[] + failedCount: number + githubUnavailable: boolean + requestFailureCount?: number + }> + /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ + fetchWorkItemsNextPage: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + perRepoLimit: number, + displayLimit: number, + query: string, + page: number, + options?: Pick + ) => Promise<{ + items: GitHubWorkItem[] + failedCount: number + errorTypes: ClassifiedError['type'][] + }> + /** Count items and derive pages from the largest per-repo result set. */ + countWorkItemsAcrossRepos: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + query: string, + perRepoLimit: number + ) => Promise<{ totalCount: number; totalPages: number }> + /** Fire-and-forget prefetch to warm the cache before the page mounts (hover/focus of the "new workspace" buttons). */ + prefetchWorkItems: ( + repoId: string, + repoPath: string, + limit?: number, + query?: string, + options?: { sourceContext?: TaskSourceContext | null } + ) => void + patchWorkItem: ( + itemId: string, + patch: Partial, + repoId?: string | null, + options?: GitHubPatchWorkItemOptions + ) => void + /** Monotonic counter bumped on issue-source preference flips; subscribers include it in their deps to force a re-fetch, since cache eviction alone won't trip effects keyed on selectedRepos/search/nonce. */ + workItemsInvalidationNonce: number + /** Persist the preference, update the local Repo record, and invalidate all `${repoId}::*` cache keys — not just the primary — so alternate-query lines don't serve stale results after the source flips. */ + setIssueSourcePreference: ( + repoId: string, + repoPath: string, + preference: IssueSourcePreference + ) => Promise + evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void + // ── ProjectV2 view cache ───────────────────────────────────────────── + projectViewCache: Record> + fetchProjectViewTable: ( + args: GetProjectViewTableArgs, + options?: FetchOptions + ) => Promise + updateProjectFieldValue: ( + cacheKey: string, + rowId: string, + fieldId: string, + value: GitHubProjectFieldMutationValue + ) => Promise + clearProjectFieldValue: ( + cacheKey: string, + rowId: string, + fieldId: string + ) => Promise + patchProjectIssueOrPr: ( + cacheKey: string, + rowId: string, + updates: ProjectRowContentUpdate + ) => Promise + patchProjectRowIssueType: ( + cacheKey: string, + rowId: string, + issueType: { id: string; name: string; color: string | null; description: string | null } | null + ) => Promise + /** Optimistic, IPC-free patcher for a single `projectViewCache` row's `content`; `patchWorkItem` only walks `workItemsCache` and would leave the Project view stale until the next refresh. */ + patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void +} diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts new file mode 100644 index 00000000000..1ead3a5d360 --- /dev/null +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -0,0 +1,84 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import { issueCacheKey } from './cache-identity' +import { CACHE_TTL } from './cache-policy' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { buildPRRefreshCandidate } from './worktree-refresh' + +export const createStaleWorktreeRefreshActions = ( + get: Parameters>[1] +): Pick => ({ + // Why: activation is the strongest freshness signal; route through the coordinator to keep coalescing/rate-limit guards. + refreshGitHubForWorktreeIfStale: (worktreeId) => { + const state = get() + let worktree: Worktree | undefined + for (const worktrees of Object.values(state.worktreesByRepo)) { + worktree = worktrees.find((w) => w.id === worktreeId) + if (worktree) { + break + } + } + if (!worktree) { + return + } + + const repo = state.repos.find((r) => r.id === worktree.repoId) + if (!repo) { + return + } + + const now = Date.now() + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cardProps = state.worktreeCardProperties ?? [] + const rawCardProps = cardProps as readonly string[] + const shouldRefreshPR = + state.groupBy === 'pr-status' || + (state.settings?.experimentalNewWorktreeCardStyle === true + ? cardProps.includes('status') + : cardProps.includes('pr') || rawCardProps.includes('ci')) || + rightSidebarShowsPullRequestData(state) + + if (shouldRefreshPR && !worktree.isBare && branch) { + const candidate = buildPRRefreshCandidate(state, worktree) + if (candidate) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: true, + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) + } + } + } + + if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + const issueEntry = state.issueCache[issueKey] + if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { + void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + } + } + } +}) diff --git a/src/renderer/src/store/github/work-item-aggregate-actions.ts b/src/renderer/src/store/github/work-item-aggregate-actions.ts new file mode 100644 index 00000000000..3bb9d20e872 --- /dev/null +++ b/src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -0,0 +1,263 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { + isGitHubWorkItemsSshRemoteRequiredError, + sortWorkItemsByNumber, + PER_REPO_FETCH_LIMIT +} from '../../../../shared/work-items' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { + GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, + isGitHubWorkItemsQueryTooLarge +} from '../slices/github-work-items-query-bounds' +import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' +import { GITHUB_SEARCH_RESULT_WINDOW, isFresh, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightWorkItemsRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { + countGitHubWorkItemsForRepo, + getGitHubWorkItemRequestContext, + getGitHubWorkItemSourceSettings, + getWorkItemsCacheKeyForOwner, + isGitHubUnavailableWorkItemsError, + listGitHubWorkItemsForRepo +} from './work-item-routing' + +export const createWorkItemAggregateActions = ( + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'fetchWorkItemsAcrossRepos' + | 'fetchWorkItemsNextPage' + | 'countWorkItemsAcrossRepos' + | 'prefetchWorkItems' +> => ({ + fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { items: [], failedCount: 0, githubUnavailable: false } + } + const state = get() + let failedCount = 0 + let requestFailureCount = 0 + let unavailableFailureCount = 0 + let skippedSourceCount = 0 + const perProjectResults = await Promise.all( + repos.map(async (r) => { + try { + return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { + ...options, + sourceContext: r.sourceContext ?? options?.sourceContext + }) + } catch (err) { + // Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute. + // Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote. + if (isGitHubWorkItemsSshRemoteRequiredError(err)) { + if (options?.requireComplete) { + requestFailureCount += 1 + failedCount += 1 + } + skippedSourceCount += 1 + return [] as GitHubWorkItem[] + } + requestFailureCount += 1 + if (isGitHubUnavailableWorkItemsError(err)) { + unavailableFailureCount += 1 + } + const key = + r.sourceContext?.provider === 'github' + ? workItemsCacheKey( + r.repoId, + perRepoLimit, + query, + getTaskSourceCacheScope(r.sourceContext) + ) + : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) + const cached = get().workItemsCache[key]?.data + if (cached && options?.allowStaleFallback !== false) { + console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) + return cached + } + console.warn(`[workItems] ${r.repoId} failed:`, err) + failedCount += 1 + return [] as GitHubWorkItem[] + } + }) + ) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) + // Why: only claim global unavailability when every eligible source failed for a reachability reason; skipped SSH repos aren't GitHub sources here. + const githubUnavailable = + requestFailureCount > 0 && + requestFailureCount === repos.length - skippedSourceCount && + unavailableFailureCount === requestFailureCount + return { + items: merged, + failedCount, + githubUnavailable, + ...(requestFailureCount > 0 ? { requestFailureCount } : {}) + } + }, + + fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { items: [], failedCount: 0, errorTypes: [] } + } + let failedCount = 0 + const errorTypes: ClassifiedError['type'][] = [] + const perProjectResults = await Promise.all( + repos.map(async (r) => { + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + r.repoId, + r.path, + r.sourceContext + ) + await acquireWorkItemSlot() + try { + const envelope = await listGitHubWorkItemsForRepo(requestContext, { + limit: perRepoLimit, + query: query || undefined, + page, + ...(options?.noCache ? { noCache: true } : {}) + }) + // Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6). + if (envelope.errors?.issues) { + const { type, message } = envelope.errors.issues + // Why: only the 1000-result-window 422 may drive the unreachable + // clamp; demote other validation errors so they read as failures. + errorTypes.push( + type === 'validation_error' && + !GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message) + ? 'unknown' + : type + ) + console.warn( + `[workItems] next page ${r.repoId} issues-side partial failure:`, + envelope.errors.issues + ) + } + if (envelope.errors?.prs) { + // Why: the window 422 is issue-side only — a PR-side validation + // error must never join the unreachable signal. + const { type } = envelope.errors.prs + errorTypes.push(type === 'validation_error' ? 'unknown' : type) + console.warn( + `[workItems] next page ${r.repoId} prs-side partial failure:`, + envelope.errors.prs + ) + } + if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { + failedCount += 1 + return [] as GitHubWorkItem[] + } + return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId })) + } catch (err) { + if (isGitHubWorkItemsSshRemoteRequiredError(err)) { + if (options?.requireComplete) { + failedCount += 1 + } + return [] as GitHubWorkItem[] + } + console.warn(`[workItems] next page ${r.repoId} failed:`, err) + failedCount += 1 + return [] as GitHubWorkItem[] + } finally { + releaseWorkItemSlot() + } + }) + ) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) + return { items: merged, failedCount, errorTypes } + }, + + countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { totalCount: 0, totalPages: 0 } + } + const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) + // Why: GitHub 422s pages that start past its 1000-result search window. + const maxReachablePages = Math.max(1, Math.ceil(GITHUB_SEARCH_RESULT_WINDOW / normalizedLimit)) + const counts = await Promise.all( + repos.map(async (r) => { + // Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403. + await acquireWorkItemSlot() + try { + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + r.repoId, + r.path, + r.sourceContext + ) + return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) + } catch { + return 0 + } finally { + releaseWorkItemSlot() + } + }) + ) + return { + totalCount: counts.reduce((sum, count) => sum + count, 0), + // Why: repos advance independently by page, so take the max across repos — a sum/page-width undercounts when one repo owns most results. + totalPages: counts.reduce( + (maxPages, count) => + Math.max(maxPages, Math.min(Math.ceil(count / normalizedLimit), maxReachablePages)), + 0 + ) + } + }, + + prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return + } + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const key = + options?.sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) + : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) + const cached = get().workItemsCache[key] + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + repoId, + repoPath, + options?.sourceContext + ) + const inflightKey = workItemsInflightRequestKey(key, requestContext.target) + if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(inflightKey)) { + return + } + void get() + .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) + .catch(() => {}) + } +}) diff --git a/src/renderer/src/store/github/work-item-fetch-actions.ts b/src/renderer/src/store/github/work-item-fetch-actions.ts new file mode 100644 index 00000000000..722556ee0ff --- /dev/null +++ b/src/renderer/src/store/github/work-item-fetch-actions.ts @@ -0,0 +1,235 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { WorkItemsCacheError } from './cache-model' +import { isGitHubWorkItemsSshRemoteRequiredError } from '../../../../shared/work-items' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { isGitHubWorkItemsQueryTooLarge } from '../slices/github-work-items-query-bounds' +import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' +import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightWorkItemsRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { + getGitHubWorkItemRequestContext, + getGitHubWorkItemSourceCacheScope, + getGitHubWorkItemSourceHostId, + getGitHubWorkItemSourceSettings, + getWorkItemsCacheKeyForOwner, + listGitHubWorkItemsForRepo +} from './work-item-routing' + +export const createWorkItemFetchActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'getCachedWorkItems' + | 'getWorkItemsSourcesAndError' + | 'getWorkItemsAnySourcesForRepo' + | 'fetchWorkItems' +> => ({ + getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return null + } + const state = get() + const key = + sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) + : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) + return get().workItemsCache[key]?.data ?? null + }, + + getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { sources: null, error: null } + } + const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) + const entry = get().workItemsCache[key] + return { + sources: entry?.sources ?? null, + error: entry?.error ?? null + } + }, + + getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { + const cache = get().workItemsCache + const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) + const primary = cache[primaryKey]?.sources + if (primary) { + return primary + } + const prefix = primaryKey + for (const [key, entry] of Object.entries(cache)) { + if (key.startsWith(prefix) && entry.sources) { + return entry.sources + } + } + return null + }, + + fetchWorkItems: async ( + repoId, + repoPath, + limit, + query, + options + ): Promise => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return [] + } + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) + const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) + const key = workItemsCacheKey(repoId, limit, query, cacheScope) + const cached = get().workItemsCache[key] + if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { + return cached.data ?? [] + } + + const requestInvalidationNonce = requestState.workItemsInvalidationNonce + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + repoId, + repoPath, + options?.sourceContext + ) + const inflightKey = workItemsInflightRequestKey(key, requestContext.target) + const existing = inflightWorkItemsRequests.get(inflightKey) + if (existing) { + // Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too). + if ( + (options?.force && !existing.force) || + (options?.noCache && !existing.noCache) || + (options?.requireComplete && !existing.requireComplete) + ) { + await existing.promise.catch(() => {}) + } else { + return existing.promise + } + } + + const request = (async () => { + await acquireWorkItemSlot() + try { + const envelope = await listGitHubWorkItemsForRepo(requestContext, { + limit, + query: query || undefined, + ...(options?.noCache ? { noCache: true } : {}) + }) + // Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id. + const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId })) + if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { + throw new Error('GitHub work-item fetch returned a partial result.') + } + // Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2). + const issuesError = envelope.errors?.issues + // Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools. + if (issuesError && !envelope.sources.issues) { + console.warn( + '[workItems] dropping issues-side error with no resolved source:', + issuesError + ) + } + const errorForCache: WorkItemsCacheError | undefined = + issuesError && envelope.sources.issues + ? { ...issuesError, source: envelope.sources.issues } + : undefined + const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) + const currentHostId = getGitHubWorkItemSourceHostId( + get(), + currentRepo, + options?.sourceContext + ) + // Why: repo ownership changed, so this response belongs to an older execution-host bucket (host focus changes alone are fine). + if ((currentHostId ?? null) !== (ownerHostId ?? null)) { + return items + } + // Why: the old promise can still settle after the in-flight clear; don't let pre-flip source data repopulate the cache once the invalidation nonce changed. + if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { + return items + } + // Why: TaskPage useShallow-selects cache entry refs. A new { ...entry, fetchedAt } + // still remaps every visible row. IPC structuredClone rebuilds nested records, so + // data === previous.data never holds — reconcile structurally, then either mutate + // fetchedAt in place or write one entry that keeps unchanged row/meta refs. + set((s) => { + const previousEntry = s.workItemsCache[key] + const previousData = previousEntry?.data ?? [] + const reconciled = reconcileCatalogRows( + previousData, + items, + (row) => `${row.repoId}\0${row.id}` + ) + const nextFellBack = envelope.issueSourceFellBack ? true : undefined + const sourcesUnchanged = structuralValuesEqual(previousEntry?.sources, envelope.sources) + const errorUnchanged = structuralValuesEqual(previousEntry?.error, errorForCache) + const fellBackUnchanged = previousEntry?.issueSourceFellBack === nextFellBack + if ( + previousEntry && + reconciled === previousData && + sourcesUnchanged && + errorUnchanged && + fellBackUnchanged + ) { + previousEntry.fetchedAt = Date.now() + return {} + } + const previousSources = previousEntry?.sources + const previousError = previousEntry?.error + return { + workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, { + // Why: `reconciled` already is `previousEntry.data` when nothing changed — + // reconcileCatalogRows returns the previous array on a structural match. + data: reconciled, + fetchedAt: Date.now(), + sources: + sourcesUnchanged && previousSources !== undefined + ? previousSources + : envelope.sources, + ...(errorForCache + ? { + error: + errorUnchanged && previousError !== undefined ? previousError : errorForCache + } + : {}), + ...(nextFellBack ? { issueSourceFellBack: true } : {}) + }) + } + }) + return items + } catch (err) { + // Why: rethrow but keep the stale cache entry so the UI still renders while the user retries. + if (!isGitHubWorkItemsSshRemoteRequiredError(err)) { + console.error('Failed to fetch GitHub work items:', err) + } + throw err + } finally { + releaseWorkItemSlot() + inflightWorkItemsRequests.delete(inflightKey) + } + })() + + inflightWorkItemsRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.noCache), + requireComplete: Boolean(options?.requireComplete) + }) + return request + } +}) diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts new file mode 100644 index 00000000000..e1d02a09d3b --- /dev/null +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -0,0 +1,133 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { toast } from 'sonner' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { CacheEntry } from './cache-model' +import { evictRepoCacheEntries, repoCacheKeyPrefixes } from './cache-identity' +import { ERROR_TOAST_DURATION } from './cache-policy' +import { clearInflightWorkItemsForRepo } from './request-coordination' + +export const createWorkItemMutationActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + patchWorkItem: (itemId, patch, repoId, options) => { + set((s) => { + const nextCache = { ...s.workItemsCache } + let changed = false + const sourceScope = + options?.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(options.sourceContext) + : null + for (const key of Object.keys(nextCache)) { + // Why: don't patch another host/account's visually identical issue/PR cache entry. + if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { + continue + } + const entry = nextCache[key] + if (!entry?.data) { + continue + } + // Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`. + const idx = entry.data.findIndex( + (item) => item.id === itemId && (!repoId || item.repoId === repoId) + ) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextCache[key] = { ...entry, data: updatedItems } + changed = true + } + return changed ? { workItemsCache: nextCache } : {} + }) + }, + + setIssueSourcePreference: async (repoId, repoPath, preference) => { + // Why: optimistically patch the local Repo so the segmented control updates this frame; resync via fetchRepos on IPC failure. + set((s) => ({ + repos: s.repos.map((r) => + r.id === repoId + ? { + ...r, + issueSourcePreference: preference === 'auto' ? undefined : preference + } + : r + ) + })) + try { + // Why: use the generic `repos:update` channel so a single write → single `repos:changed` broadcast re-fetches other windows. + // Why: map 'auto' to undefined so persistence drops the key entirely (see main/persistence.ts#updateRepo). + const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } + // Why: route to the repo's owner host (like updateRepo) so the write lands where the repo lives, not the focused runtime. + const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) + } catch (err) { + console.error('Failed to persist issue-source preference:', err) + // Why: without this toast the pill silently snaps back (optimistic patch + resync) and the user wouldn't know the write failed. + toast.error( + translate('auto.store.slices.github.d49ef4b944', 'Failed to save issue-source preference'), + { + duration: ERROR_TOAST_DURATION + } + ) + // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. + void get().fetchRepos() + } + // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. + clearInflightWorkItemsForRepo(repoId, repoPath) + // Why: evict AFTER the await so an overlapping fetch can't repopulate with pre-flip data; also drops legacy path-scoped keys. + set((s) => { + const prefix = `${repoId}::` + const legacyPrefix = `${repoPath}::` + const next: Record> = {} + for (const [key, entry] of Object.entries(s.workItemsCache)) { + if (!key.startsWith(prefix) && !key.startsWith(legacyPrefix)) { + next[key] = entry + } + } + // Why: the Tasks fetch effect keys on the nonce, not the cache, so bump it to re-run and re-populate the evicted entries. + return { workItemsCache: next, workItemsInvalidationNonce: s.workItemsInvalidationNonce + 1 } + }) + }, + + evictGitHubRepoCaches: (repoId, repoPath) => { + clearInflightWorkItemsForRepo(repoId, repoPath) + set((s) => { + const prefixes = repoCacheKeyPrefixes(repoId, repoPath) + const workItems = evictRepoCacheEntries(s.workItemsCache, prefixes) + const prs = evictRepoCacheEntries(s.prCache, prefixes) + const issues = evictRepoCacheEntries(s.issueCache, prefixes) + const checks = evictRepoCacheEntries(s.checksCache, prefixes) + const comments = evictRepoCacheEntries(s.commentsCache, prefixes) + const updates: Partial = {} + + if (workItems.evicted) { + updates.workItemsCache = workItems.cache + updates.workItemsInvalidationNonce = s.workItemsInvalidationNonce + 1 + } + if (prs.evicted) { + updates.prCache = prs.cache + } + if (issues.evicted) { + updates.issueCache = issues.cache + } + if (checks.evicted) { + updates.checksCache = checks.cache + } + if (comments.evicted) { + updates.commentsCache = comments.cache + } + + return updates + }) + } +}) diff --git a/src/renderer/src/store/github/work-item-routing.ts b/src/renderer/src/store/github/work-item-routing.ts new file mode 100644 index 00000000000..b47ee0501d8 --- /dev/null +++ b/src/renderer/src/store/github/work-item-routing.ts @@ -0,0 +1,223 @@ +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../../../shared/github/work-item-types' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' +import { callRuntimeRpc, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' +import { workItemsCacheKey } from './cache-identity' +import { + findRepoForGitHubOwner, + getGitHubFocusedRepoOwnerHostId, + getRuntimeRepoTarget +} from './repository-routing' + +export type GitHubWorkItemRequestContext = { + repoId: string + repoPath: string + target: GitHubWorkItemRequestTarget +} + +export type GitHubWorkItemRequestTarget = + | { kind: 'environment'; environmentId: string; runtimeRepoId: string } + | { kind: 'local' } + +export type GitHubWorkItemsListArgs = { + limit: number + query?: string + page?: number + noCache?: true +} + +export function settingsForGitHubRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned GitHub lookups run on the desktop client; host focus must not redirect them to the selected runtime. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +export function settingsForGitHubFocusedRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForGitHubRepoOwner(settings, repo) +} +export function getWorkItemsCacheKeyForOwner( + state: Partial>, + repoId: string, + limit: number, + query: string, + repoPath?: string +): string { + const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') + return workItemsCacheKey( + repoId, + limit, + query, + repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined + ) +} + +export function getGitHubWorkItemSourceHostId( + state: AppState, + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): ExecutionHostId | undefined { + if (sourceContext?.provider === 'github') { + return sourceContext.hostId + } + return repo + ? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined) + : undefined +} + +export function getGitHubWorkItemSourceCacheScope( + state: AppState, + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): string | undefined { + if (sourceContext?.provider === 'github') { + return getTaskSourceCacheScope(sourceContext) + } + return getGitHubWorkItemSourceHostId(state, repo, sourceContext) +} + +export function getGitHubWorkItemSourceSettings( + settings: AppState['settings'], + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubFocusedRepoOwner(settings, repo) +} + +export function getGitHubRepoSourceSettings( + settings: AppState['settings'], + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubRepoOwner(settings, repo) +} + +export function getGitHubWorkItemRequestContext( + state: AppState, + settings: AppState['settings'], + repoId: string, + repoPath: string, + sourceContext?: TaskSourceContext | null +): GitHubWorkItemRequestContext { + if (sourceContext?.provider === 'github') { + const parsedHost = parseExecutionHostId(sourceContext.hostId) + if (parsedHost?.kind === 'runtime') { + return { + repoId, + repoPath, + target: { + kind: 'environment', + environmentId: parsedHost.environmentId, + runtimeRepoId: sourceContext.repoId ?? repoId + } + } + } + } + const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) + return { + repoId, + repoPath, + target: runtimeRepo + ? { + kind: 'environment', + environmentId: runtimeRepo.target.environmentId, + runtimeRepoId: runtimeRepo.repo.id + } + : { kind: 'local' } + } +} + +export function listGitHubWorkItemsForRepo( + context: GitHubWorkItemRequestContext, + args: GitHubWorkItemsListArgs +): Promise>> { + if (context.target.kind === 'environment') { + return callRuntimeRpc>>( + { kind: 'environment', environmentId: context.target.environmentId }, + 'github.listWorkItems', + { + repo: context.target.runtimeRepoId, + ...args + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.listWorkItems({ + repoPath: context.repoPath, + repoId: context.repoId, + ...args + }) +} + +export function countGitHubWorkItemsForRepo( + context: GitHubWorkItemRequestContext, + args: { query?: string } +): Promise { + if (context.target.kind === 'environment') { + return callRuntimeRpc( + { kind: 'environment', environmentId: context.target.environmentId }, + 'github.countWorkItems', + { + repo: context.target.runtimeRepoId, + ...args + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.countWorkItems({ + repoPath: context.repoPath, + repoId: context.repoId, + ...args + }) +} + +export function isGitHubUnavailableWorkItemsError(error: unknown): boolean { + // Why: only `runtime_error` came from the GitHub method; other RPC transport failures ("timed out"/"unavailable") must not be blamed on GitHub. + if (error instanceof RuntimeRpcCallError && error.code !== 'runtime_error') { + return false + } + const message = error instanceof Error ? error.message : String(error) + return classifyGitHubUnavailable(message) !== null +} diff --git a/src/renderer/src/store/github/worktree-refresh.ts b/src/renderer/src/store/github/worktree-refresh.ts new file mode 100644 index 00000000000..3865c4834b1 --- /dev/null +++ b/src/renderer/src/store/github/worktree-refresh.ts @@ -0,0 +1,280 @@ +import type { AppState } from '../types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { prCacheKey } from './cache-identity' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { githubHostedReviewFallbackPRNumber } from './pr-result-routing' +import type { GitHubPRFallbackSource } from './cache-model' + +export function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { + return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null +} + +type WorktreeLookupEntry = { + first: Worktree + unique: Worktree | null +} + +export type WorktreeLookupIndex = { + byId: Map + repoHostIdsByRepoId: Map> +} + +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] + +export function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const byId = new Map() + for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { + for (const worktree of worktrees) { + const worktreeId = worktree.id + const existing = byId.get(worktreeId) + if (existing) { + existing.unique = null + } else { + byId.set(worktreeId, { first: worktree, unique: worktree }) + } + } + } + + const repoHostIdsByRepoId = new Map>() + for (const repo of state.repos ?? []) { + let hostIds = repoHostIdsByRepoId.get(repo.id) + if (!hostIds) { + hostIds = new Set() + repoHostIdsByRepoId.set(repo.id, hostIds) + } + hostIds.add(getRepoExecutionHostId(repo)) + } + return { byId, repoHostIdsByRepoId } +} + +// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. +const worktreeLookupIndexes = new WeakMap< + AppState['worktreesByRepo'], + { repos: AppState['repos']; index: WorktreeLookupIndex } +>() + +export function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const repos = state.repos ?? EMPTY_WORKTREE_REPOS + const cached = worktreeLookupIndexes.get(worktreesByRepo) + if (cached && cached.repos === repos) { + return cached.index + } + const index = buildWorktreeLookupIndex(state) + worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) + return index +} + +export function findUniqueWorktreeById( + state: AppState, + worktreeId: string, + executionHostId?: string, + lookupIndex = getWorktreeLookupIndex(state) +): Worktree | null { + const match = lookupIndex.byId.get(worktreeId)?.unique ?? null + // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. + if (!match || executionHostId === undefined) { + return match + } + const expectedHostId = normalizeExecutionHostId(executionHostId) ?? LOCAL_EXECUTION_HOST_ID + const explicitWorktreeHostId = normalizeExecutionHostId(match.hostId) + if (explicitWorktreeHostId) { + return explicitWorktreeHostId === expectedHostId ? match : null + } + const repoHostIds = lookupIndex.repoHostIdsByRepoId.get(match.repoId) + // Pre-host persisted rows are safe only when their repo has one unambiguous owner. + if (!repoHostIds || repoHostIds.size !== 1 || !repoHostIds.has(expectedHostId)) { + return null + } + return match +} + +export function isStaleExactLinkedPRLookup( + state: AppState, + worktreeId: string | undefined, + linkedPRNumber: number | null | undefined, + lookupIndex?: WorktreeLookupIndex +): boolean { + if (!worktreeId || linkedPRNumber == null) { + return false + } + const worktree = lookupIndex + ? (lookupIndex.byId.get(worktreeId)?.first ?? null) + : findWorktreeById(state, worktreeId) + return worktree?.linkedPR !== linkedPRNumber +} + +export function shouldClearDivergedLinkedMergedPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + requestHeadOid: string | null +}): boolean { + const { pr, linkedPRNumber, requestHeadOid } = args + return ( + linkedPRNumber != null && + requestHeadOid !== null && + pr?.number === linkedPRNumber && + pr.state === 'merged' && + // Head-scoped: clear only the worktree whose head diverged, so a PR-number-coalesced broadcast can't clear a sibling still on the PR's line of work. + pr.headDivergedFromMergedPRAtOid === requestHeadOid && + pr.headSha !== requestHeadOid && + pr.confirmedContainedHeadOid !== requestHeadOid + ) +} + +export function shouldApplyDivergedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + worktree.branch.replace(/^refs\/heads\//, '') === branch && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + +// Why: a linked PR is branch-scoped; it's stale once the worktree switched branches with neither push target nor HEAD at the PR head, else Checks stays pinned to the old branch's PR. +export function shouldClearBranchMismatchedLinkedOpenPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + branch: string + requestHeadOid: string | null + pushTargetBranch: string | null +}): boolean { + const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args + const headRefName = pr?.headRefName?.trim() ?? '' + const currentBranch = branch.replace(/^refs\/heads\//, '').trim() + return ( + linkedPRNumber != null && + pr?.number === linkedPRNumber && + // Draft reviews are open PRs too; don't let their distinct renderer state leave a stale durable link wedged after a branch switch. + (pr.state === 'open' || pr.state === 'draft') && + requestHeadOid !== null && + headRefName !== '' && + currentBranch !== '' && + headRefName !== currentBranch && + (pushTargetBranch === null || pushTargetBranch !== headRefName) && + // A worktree parked on the PR's head commit is the same line of work (e.g. renamed local branch); keep the link. + !(pr.headSha != null && pr.headSha === requestHeadOid) + ) +} + +export function shouldApplyBranchMismatchedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + // Branch-scoped: clear only while still on the branch the mismatch was computed against; a newer switch re-validates. + worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + +export function buildPRRefreshCandidate( + state: AppState, + worktree: Worktree, + repoPath?: string, + repoOverride?: Repo +): GitHubPRRefreshCandidate | null { + const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) + if (!repo) { + return null + } + if (isMacAppDataPath(repoPath ?? repo.path)) { + return null + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cacheKey = prCacheKey( + repoPath ?? repo.path, + repo.id, + branch, + settingsForGitHubRepoOwner(state.settings, repo), + repo.connectionId, + repo.executionHostId, + true + ) + const cachedPR = state.prCache[cacheKey]?.data ?? null + const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( + state, + repoPath ?? repo.path, + repo.id, + branch, + repo.connectionId, + repo.executionHostId, + true + ) + const cachedFallbackPRNumber = cachedPR?.number ?? null + // Why: a merged PR is a valid fallback only while the worktree sits on its head or a confirmed-contained commit — else the branch moved on. + const cachedMergedPRMovedPastHead = + worktree.linkedPR == null && + cachedPR?.state === 'merged' && + cachedPR.headSha !== worktree.head && + cachedPR.confirmedContainedHeadOid !== worktree.head + const fallbackPRNumber = + worktree.linkedPR == null && !cachedMergedPRMovedPastHead + ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) + : null + const fallbackPRSource: GitHubPRFallbackSource | null = + worktree.linkedPR != null || fallbackPRNumber == null + ? null + : cachedFallbackPRNumber != null + ? 'pr-cache' + : 'hosted-review' + const sshStatus = repo.connectionId + ? state.sshConnectionStates.get(repo.connectionId)?.status + : null + return { + repoId: repo.id, + repoPath: repoPath ?? repo.path, + repoKind: repo.kind ?? 'git', + branch, + cacheKey, + worktreeId: worktree.id, + currentHeadOid: worktree.head ?? null, + // Why: persisted linked PR metadata is exact; PR cache numbers are only fallback hints after branch-lookup misses. + linkedPRNumber: worktree.linkedPR ?? null, + fallbackPRNumber, + fallbackPRSource, + isBare: worktree.isBare, + isArchived: worktree.isArchived, + connectionId: repo.connectionId ?? null, + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId + ? sshStatus === 'connected' + ? 'connected' + : 'disconnected' + : 'unknown', + cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null, + cachedHasPR: cachedPR ? true : state.prCache[cacheKey] ? false : null, + cachedPRState: cachedPR?.state ?? null, + cachedChecksStatus: cachedPR?.checksStatus ?? null, + cachedMergeable: cachedPR?.mergeable ?? null, + cachedMergeStateStatus: cachedPR?.mergeStateStatus ?? null + } +} diff --git a/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts b/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts index 60a17379232..cbb722049d3 100644 --- a/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts +++ b/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { shouldClearBranchMismatchedLinkedOpenPR } from './github' +import { shouldClearBranchMismatchedLinkedOpenPR } from '../github/worktree-refresh' import type { PRInfo } from '../../../../shared/github/pull-request-types' import './github-slice-test-harness' diff --git a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts index 9030ac4d404..8176bf64f6e 100644 --- a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts +++ b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { issueCacheKey, workItemsCacheKey } from './github' +import { issueCacheKey, workItemsCacheKey } from '../github/cache-identity' import { createTestStore, githubSourceContext, @@ -62,6 +62,58 @@ describe('createGitHubSlice.evictGitHubRepoCaches', () => { expect(state.workItemsInvalidationNonce).toBe(5) }) + it('preserves runtime and explicit-source-prefixed entries during repo eviction', () => { + const store = createTestStore() + const sourceContext = githubSourceContext('runtime:source-env', 'source-repo') + const runtimeKey = workItemsCacheKey('repo-1', 20, '', 'runtime:owner-env') + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ + workItemsInvalidationNonce: 4, + workItemsCache: { + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + } + }) + + store.getState().evictGitHubRepoCaches('repo-1', '/repo/one') + + expect(store.getState().workItemsCache).toEqual({ + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + }) + expect(store.getState().workItemsInvalidationNonce).toBe(4) + }) + + it('preference invalidation removes local keys but preserves prefixed host/source keys', async () => { + const store = createTestStore() + const sourceContext = githubSourceContext('runtime:source-env', 'source-repo') + const localKey = workItemsCacheKey('repo-1', 20, '') + const runtimeKey = workItemsCacheKey('repo-1', 20, '', 'runtime:owner-env') + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ + repos: [{ id: 'repo-1', path: '/repo/one', name: 'one', kind: 'git' }], + workItemsInvalidationNonce: 4, + workItemsCache: { + [localKey]: { data: [], fetchedAt: 1 }, + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + } + } as unknown as Partial) + mockApi.repos.update.mockResolvedValueOnce(undefined) + + await store.getState().setIssueSourcePreference('repo-1', '/repo/one', 'origin') + + expect(store.getState().workItemsCache).toEqual({ + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + }) + expect(store.getState().workItemsInvalidationNonce).toBe(5) + expect(mockApi.repos.update).toHaveBeenCalledWith({ + repoId: 'repo-1', + updates: { issueSourcePreference: 'origin' } + }) + }) + it('does not bump the work-item invalidation nonce when no work-item entries are evicted', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/github-checks-cache.test.ts b/src/renderer/src/store/slices/github-checks-cache.test.ts index 0808acba490..2b6c4c7f4dd 100644 --- a/src/renderer/src/store/slices/github-checks-cache.test.ts +++ b/src/renderer/src/store/slices/github-checks-cache.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' -import { createGitHubSlice, prChecksCacheSuffix } from './github' +import { createGitHubSlice } from './github' +import { prChecksCacheSuffix } from '../github/cache-identity' import { createHostedReviewSlice } from './hosted-review' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import type { AppState } from '../types' diff --git a/src/renderer/src/store/slices/github-issue-state-machine.test.ts b/src/renderer/src/store/slices/github-issue-state-machine.test.ts new file mode 100644 index 00000000000..4df798c799e --- /dev/null +++ b/src/renderer/src/store/slices/github-issue-state-machine.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { issueCacheKey } from '../github/cache-identity' +import { + createTestStore, + githubSourceContext, + mockApi, + resetRemoteRuntimeMocks +} from './github-slice-test-harness' + +describe('createGitHubSlice.fetchIssue state machine', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resetRemoteRuntimeMocks() + mockApi.gh.issue.mockResolvedValue(null) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('treats a fresh null as a valid hit even when force is requested', async () => { + const store = createTestStore() + const cacheKey = issueCacheKey('/repo/one', 'repo-1', 42) + store.setState({ + issueCache: { + [cacheKey]: { data: null, fetchedAt: Date.now() } + } + }) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', force: true }) + ).resolves.toBeNull() + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('deduplicates a forced caller onto the same in-flight request', async () => { + const store = createTestStore() + const pendingIssue = Promise.withResolvers() + const issue = { number: 42, title: 'Issue' } as IssueInfo + mockApi.gh.issue.mockReturnValueOnce(pendingIssue.promise) + + const first = store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1' }) + const forced = store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', force: true }) + pendingIssue.resolve(issue) + + await expect(Promise.all([first, forced])).resolves.toEqual([issue, issue]) + await vi.advanceTimersByTimeAsync(1_000) + expect(mockApi.gh.issue).toHaveBeenCalledTimes(1) + }) + + it('persists a source-scoped five-minute null entry after a thrown fetch', async () => { + const store = createTestStore() + const sourceContext = githubSourceContext('local', 'source-repo') + const cacheKey = `${getTaskSourceCacheScope(sourceContext)}::repo-1::42` + mockApi.gh.issue.mockRejectedValueOnce(new Error('offline')) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', sourceContext }) + ).resolves.toBeNull() + + const failedEntry = store.getState().issueCache[cacheKey] + expect(failedEntry?.data).toBeNull() + expect(Date.now() - (failedEntry?.fetchedAt ?? 0)).toBeLessThan(300_000) + expect(mockApi.cache.setGitHub).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + expect(mockApi.cache.setGitHub).toHaveBeenCalledWith({ + cache: { + pr: store.getState().prCache, + issue: store.getState().issueCache + } + }) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', sourceContext, force: true }) + ).resolves.toBeNull() + expect(mockApi.gh.issue).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts index 6024d2b9d39..63ca5ab4192 100644 --- a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, installLinkedPRClearStub, diff --git a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts index a4871d2c20d..8bbde6cec19 100644 --- a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _clearGitHubPRRefreshStartedEntriesForTest, _getGitHubPRRequestGenerationCountForTest -} from './github' +} from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts index f6df1a86778..ea32952a89e 100644 --- a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts b/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts index 2fb6a8b74e6..50850ac19dc 100644 --- a/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prChecksCacheSuffix } from '../github/cache-identity' import { _clearGitHubPRRefreshStartedEntriesForTest, - _getGitHubPRRefreshStartedEntryCountForTest, - prChecksCacheSuffix -} from './github' + _getGitHubPRRefreshStartedEntryCountForTest +} from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 2f0559d6409..418b6648fa0 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, installLinkedPRClearStub, diff --git a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts index a2b1147e48c..e1c40623901 100644 --- a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts +++ b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { prChecksCacheSuffix, prCommentsCacheSuffix } from './github' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '../github/cache-identity' import { createTestStore, githubSourceContext, diff --git a/src/renderer/src/store/slices/github-pr-comments.test.ts b/src/renderer/src/store/slices/github-pr-comments.test.ts index 87e74aeef10..8432f944fd4 100644 --- a/src/renderer/src/store/slices/github-pr-comments.test.ts +++ b/src/renderer/src/store/slices/github-pr-comments.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mergePRCommentIntoList, prCommentsCacheSuffix } from './github' +import { prCommentsCacheSuffix } from '../github/cache-identity' +import { mergePRCommentIntoList } from '../github/pr-comment-cache' +import type { PRComment } from '../../../../shared/github/comment-types' import { createTestStore, githubSourceContext, @@ -184,6 +186,31 @@ describe('createGitHubSlice.fetchPRComments', () => { expect(mockApi.gh.prComments).not.toHaveBeenCalled() }) + it('deduplicates a forced comment fetch onto an existing non-forced request', async () => { + const store = createTestStore() + const pendingComments = Promise.withResolvers() + const comments: PRComment[] = [ + { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'pending', + createdAt: '2026-08-23T00:00:00Z', + url: '' + } + ] + mockApi.gh.prComments.mockReturnValueOnce(pendingComments.promise) + + const first = store.getState().fetchPRComments('/repo/one', 12, { repoId: 'repo-1' }) + const forced = store + .getState() + .fetchPRComments('/repo/one', 12, { repoId: 'repo-1', force: true }) + + expect(mockApi.gh.prComments).toHaveBeenCalledTimes(1) + pendingComments.resolve(comments) + await expect(Promise.all([first, forced])).resolves.toEqual([comments, comments]) + }) + it('bounds PR comment cache entries across many repos', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github-project-request-coordination.test.ts b/src/renderer/src/store/slices/github-project-request-coordination.test.ts new file mode 100644 index 00000000000..96e4632e909 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-request-coordination.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubProjectTable } from '../../../../shared/github/project-types' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import { projectViewCacheKey } from '../github/cache-identity' +import { createTestStore, mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' + +function makeTable(title = 'Roadmap'): GitHubProjectTable { + return { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title, + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } +} + +const request = { + owner: 'acme', + ownerType: 'organization' as const, + projectNumber: 1, + viewId: 'view-1' +} + +const cacheKey = projectViewCacheKey('organization', 'acme', 1, 'view-1') + +describe('createGitHubSlice.fetchProjectViewTable coordination', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('returns a fresh known-view cache entry without starting a request', async () => { + const store = createTestStore() + const table = makeTable() + store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) + + await expect(store.getState().fetchProjectViewTable(request)).resolves.toEqual({ + ok: true, + data: table + }) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + }) + + it('deduplicates identical non-forced in-flight requests', async () => { + const store = createTestStore() + const pending = Promise.withResolvers() + mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) + + const first = store.getState().fetchProjectViewTable(request) + const duplicate = store.getState().fetchProjectViewTable(request) + pending.resolve({ ok: true, data: makeTable() }) + + await expect(Promise.all([first, duplicate])).resolves.toEqual([ + { ok: true, data: makeTable() }, + { ok: true, data: makeTable() } + ]) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + }) + + it('lets each forced waiter start after a weaker request settles', async () => { + const store = createTestStore() + const weak = Promise.withResolvers() + mockApi.gh.getProjectViewTable + .mockReturnValueOnce(weak.promise) + .mockResolvedValue({ ok: true, data: makeTable('forced') }) + + const first = store.getState().fetchProjectViewTable(request) + const forcedOne = store.getState().fetchProjectViewTable(request, { force: true }) + const forcedTwo = store.getState().fetchProjectViewTable(request, { force: true }) + weak.resolve({ ok: true, data: makeTable('weak') }) + + await expect(Promise.all([first, forcedOne, forcedTwo])).resolves.toHaveLength(3) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(3) + }) + + it('stamps a classified failure onto stale data only when the view key is known', async () => { + const store = createTestStore() + const stale = makeTable('stale') + const error = { type: 'network_error' as const, message: 'offline' } + store.setState({ projectViewCache: { [cacheKey]: { data: stale, fetchedAt: 1 } } }) + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) + + await expect(store.getState().fetchProjectViewTable(request, { force: true })).resolves.toEqual( + { + ok: false, + error + } + ) + expect(store.getState().projectViewCache[cacheKey]).toMatchObject({ data: stale, error }) + + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) + await expect( + store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ).resolves.toEqual({ ok: false, error }) + expect(Object.keys(store.getState().projectViewCache)).toEqual([cacheKey]) + }) + it('wraps an unexpected transport throw without stamping an unknown selector', async () => { + const store = createTestStore() + mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) + + await expect( + store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ).resolves.toEqual({ + ok: false, + error: { type: 'unknown', message: 'transport offline' } + }) + expect(store.getState().projectViewCache).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/github-project-view-tables.test.ts b/src/renderer/src/store/slices/github-project-view-tables.test.ts index 64bd72e91a0..faa589824c3 100644 --- a/src/renderer/src/store/slices/github-project-view-tables.test.ts +++ b/src/renderer/src/store/slices/github-project-view-tables.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { projectViewCacheKey } from './github' +import { projectViewCacheKey } from '../github/cache-identity' import { createTestStore, mockApi, @@ -406,6 +406,77 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('optimistically patches a project field and rolls back an ok:false result', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey('organization', 'acme', 1, 'view-1') + store.setState({ + settings: { activeRuntimeEnvironmentId: null }, + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [{ id: 'field-1', name: 'Notes', dataType: 'TEXT', kind: 'text' }], + groupByFields: [], + sortByFields: [] + }, + rows: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: { + 'field-1': { kind: 'text', fieldId: 'field-1', text: 'before' } + } + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial) + const mutationResult = Promise.withResolvers<{ ok: false; error: string }>() + mockApi.gh.updateProjectItemField.mockReturnValueOnce(mutationResult.promise) + + const mutation = store + .getState() + .updateProjectFieldValue(cacheKey, 'row-1', 'field-1', { kind: 'text', text: 'after' }) + expect( + store.getState().projectViewCache[cacheKey]?.data?.rows[0]?.fieldValuesByFieldId['field-1'] + ).toEqual({ kind: 'text', fieldId: 'field-1', text: 'after' }) + + mutationResult.resolve({ ok: false, error: 'rejected' }) + await expect(mutation).resolves.toEqual({ ok: false, error: 'rejected' }) + expect( + store.getState().projectViewCache[cacheKey]?.data?.rows[0]?.fieldValuesByFieldId['field-1'] + ).toEqual({ kind: 'text', fieldId: 'field-1', text: 'before' }) + }) + it('bounds project view table cache entries across many projects', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts new file mode 100644 index 00000000000..30717fca0d9 --- /dev/null +++ b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import type { ListWorkItemsResult } from '../../../../shared/github/work-item-types' +import { createTestStore, mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' + +const emptyWorkItems: ListWorkItemsResult = { + items: [], + sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null } +} + +describe('GitHub provider request concurrency', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('shares eight FIFO slots across work items and Projects and hands off after rejection', async () => { + const store = createTestStore() + const workRequests = Array.from({ length: 9 }, () => + Promise.withResolvers>() + ) + let workCall = 0 + mockApi.gh.listWorkItems.mockImplementation(() => workRequests[workCall++].promise) + const projectRequest = Promise.withResolvers() + mockApi.gh.getProjectViewTable.mockReturnValueOnce(projectRequest.promise) + + const activeWork = Array.from({ length: 8 }, (_, index) => + store + .getState() + .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') + .catch(() => []) + ) + const queuedProject = store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + const queuedWork = store + .getState() + .fetchWorkItems('repo-queued', '/repo/queued', 20, '') + .catch(() => []) + await Promise.resolve() + + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(8) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + + workRequests[0].reject(new Error('first request failed')) + await activeWork[0] + await Promise.resolve() + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(8) + + projectRequest.resolve({ + ok: false, + error: { type: 'network_error', message: 'project failed' } + }) + await queuedProject + await Promise.resolve() + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(9) + + for (const request of workRequests.slice(1)) { + request.resolve(emptyWorkItems) + } + await Promise.all([...activeWork.slice(1), queuedWork]) + }) +}) diff --git a/src/renderer/src/store/slices/github-review-thread-actions.test.ts b/src/renderer/src/store/slices/github-review-thread-actions.test.ts new file mode 100644 index 00000000000..41c12410c10 --- /dev/null +++ b/src/renderer/src/store/slices/github-review-thread-actions.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { AppState } from '../types' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from '../github/cache-identity' +import { + createTestStore, + mockApi, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from './github-slice-test-harness' + +const repoPath = '/repo' +const repoId = 'repo-id' +const threadId = 'thread-1' + +function makeComments(isResolved = false): PRComment[] { + return [ + { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'review', + createdAt: '2026-08-23T00:00:00Z', + url: '', + threadId, + isResolved + } + ] +} + +function installLocalComments(store: ReturnType): string { + const cacheKey = sourceScopedRepoCacheKey(repoPath, repoId, prCommentsCacheSuffix(12)) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { [cacheKey]: { data: makeComments(), fetchedAt: 1 } } + } as unknown as Partial) + return cacheKey +} + +describe('createGitHubSlice.resolveReviewThread', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('optimistically resolves and routes through the repository runtime', async () => { + const store = createTestStore() + const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(12), + settings, + null, + 'runtime:env-1', + undefined, + true + ) + store.setState({ + settings, + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], + commentsCache: { [cacheKey]: { data: makeComments(), fetchedAt: 1 } } + } as unknown as Partial) + const runtimeResult = Promise.withResolvers<{ + id: string + ok: true + result: boolean + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockReturnValueOnce(runtimeResult.promise) + + const pending = store.getState().resolveReviewThread(repoPath, 12, threadId, true, { + repoId + }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + runtimeResult.resolve({ + id: 'rpc-thread', + ok: true, + result: true, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect(pending).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.resolveReviewThread', + params: { repo: repoId, threadId, resolve: true, prRepo: null }, + timeoutMs: 30_000 + }) + expect(mockApi.gh.resolveReviewThread).not.toHaveBeenCalled() + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + }) + + it('rolls back the captured comment array when GitHub returns false', async () => { + const store = createTestStore() + const cacheKey = installLocalComments(store) + const result = Promise.withResolvers() + mockApi.gh.resolveReviewThread.mockReturnValueOnce(result.promise) + + const pending = store.getState().resolveReviewThread(repoPath, 12, threadId, true, { repoId }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + result.resolve(false) + + await expect(pending).resolves.toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual(makeComments()) + }) + + it('swallows a thrown transport error and rolls back the captured comment array', async () => { + const store = createTestStore() + const cacheKey = installLocalComments(store) + mockApi.gh.resolveReviewThread.mockRejectedValueOnce(new Error('offline')) + + await expect( + store.getState().resolveReviewThread(repoPath, 12, threadId, true, { repoId }) + ).resolves.toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual(makeComments()) + }) +}) diff --git a/src/renderer/src/store/slices/github-slice-test-harness.ts b/src/renderer/src/store/slices/github-slice-test-harness.ts index f274d58d54c..ef7aede802e 100644 --- a/src/renderer/src/store/slices/github-slice-test-harness.ts +++ b/src/renderer/src/store/slices/github-slice-test-harness.ts @@ -63,6 +63,9 @@ export const mockApi = { updatePullRequestBySlug: stubMock(), updateIssueTypeBySlug: stubMock() }, + repos: { + update: stubMock().mockResolvedValue(undefined) + }, hostedReview: { forBranch: stubMock().mockResolvedValue(null), getCreationEligibility: stubMock(), diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index cf8b529e9f5..ef22da072d1 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import { createTestStore, githubSourceContext, diff --git a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts index 6450112a8f1..5053883c11b 100644 --- a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts +++ b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import { createTestStore, mockApi, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 541b88db919..126c75710af 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1,2087 +1,29 @@ -/* eslint-disable max-lines -- Why: the GitHub slice co-locates cache + fetch logic for PR/issue/checks/comments so dedup and invalidation patterns stay consistent. */ import type { StateCreator } from 'zustand' -import { toast } from 'sonner' import type { AppState } from '../types' -import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' -import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' -import type { ClassifiedError } from '../../../../shared/classified-error' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' -import type { - GitHubCommentResult, - GitHubReactionContent, - PRComment -} from '../../../../shared/github/comment-types' -import type { - GitHubPRRefreshAlias, - GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, - GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, - PRRefreshErrorType, - PRRefreshOutcome -} from '../../../../shared/github/pull-request-refresh-types' -import type { - GitHubOwnerRepo, - IssueInfo, - PRInfo -} from '../../../../shared/github/pull-request-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../../../../shared/github/work-item-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { IssueSourcePreference, Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { - GitHubProjectFieldMutationValue, - GitHubProjectRow, - GitHubProjectTable -} from '../../../../shared/github/project-types' -import type { - GetProjectViewTableResult, - GitHubProjectMutationResult, - GitHubProjectViewError -} from '../../../../shared/github/project-result-types' -import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { GitHubSlice as GitHubSliceContract } from '../github/slice-types' import { - isGitHubWorkItemsSshRemoteRequiredError, - sortWorkItemsByNumber, - PER_REPO_FETCH_LIMIT -} from '../../../../shared/work-items' -import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - RuntimeRpcCallError -} from '../../runtime/runtime-rpc-client' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { settingsForProjectRowOwner } from './github-project-row-owner' -import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' -import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' -import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key' -import { - GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, - isGitHubWorkItemsQueryTooLarge -} from './github-work-items-query-bounds' -import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' -import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' -import { translate } from '@/i18n/i18n' -import { - LOCAL_EXECUTION_HOST_ID, - getRepoExecutionHostId, - getSettingsFocusedExecutionHostId, - normalizeExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' -import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' -import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' -import { reconcileCatalogRows } from './repo-identity-reconcile' -import { structuralValuesEqual } from '../../../../shared/structural-value-equality' - -// ─── ProjectV2 cache types ──────────────────────────────────────────── -// Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. -export type ProjectViewCacheEntry = { - data: T | null - fetchedAt: number - error?: GitHubProjectViewError -} - -export type ProjectRowContentUpdate = { - title?: string - body?: string - addLabels?: string[] - removeLabels?: string[] - addAssignees?: string[] - removeAssignees?: string[] -} - -export type GitHubPatchWorkItemOptions = { - sourceContext?: TaskSourceContext | null -} - -/** Optimistic, IPC-free patch shape for `projectViewCache` rows; uses full `labels`/`assignees` arrays (not add/remove deltas) to match the dialog's local state and avoid set-merge at the call site. */ -export type ProjectRowContentPatch = { - title?: string - body?: string - /** Lowercase renderer state vocab, translated to GitHub's UPPERCASE `row.content.state` on apply; merged/draft pass through though the dialog only flips open↔closed today. */ - state?: 'open' | 'closed' | 'merged' | 'draft' - labels?: string[] - assignees?: string[] -} - -// Why: queryOverride is part of the cache key; `undefined` = the view's stored filter, `''` = a distinct "no filter" override that gets its own entry. -function queryOverrideKeyPart(queryOverride: string | undefined): string { - if (queryOverride === undefined) { - return '' - } - return `:q=${queryOverride}` -} - -function getRuntimeRepoTarget( - state: AppState, - repoPath: string, - settings: AppState['settings'] = state.settings -): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { - const target = getActiveRuntimeTarget(settings) - if (target.kind !== 'environment') { - return null - } - const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) - return repo ? { target, repo } : null -} - -function getPRRefreshOwnerRuntimeEnvironmentId( - candidate: Pick -): string | null { - const parsed = parseExecutionHostId(candidate.executionHostId) - if (parsed?.kind === 'runtime') { - return parsed.environmentId - } - const cacheScope = candidate.cacheKey.split('::', 1)[0] - const cacheScopeHost = parseExecutionHostId(cacheScope) - return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null -} - -function getPRRefreshRuntimeRepoTarget( - state: AppState, - candidate: GitHubPRRefreshCandidate -): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { - const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) - if (!ownerRuntimeEnvironmentId) { - return null - } - // Why: PR refreshes must follow the repo owner host, not the Active Server dropdown (a runtime-owned worktree can show while Local is focused). - return getRuntimeRepoTarget( - state, - candidate.repoPath, - state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) - ) -} - -function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { - // Why: the local coordinator owns local git + SSH-bridge refreshes; runtime-owned and disconnected-SSH repos must not hit the IPC crash path. - if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { - return false - } - return !candidate.connectionId || candidate.connectionState === 'connected' -} - -function enqueueLocalGitHubPRRefresh( - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority: number - }, - onNotQueued?: () => void | Promise -): void { - const enqueue = window.api.gh.enqueuePRRefresh - if (!enqueue) { - return - } - // Why: renderer refresh triggers are best-effort — main may reject stale paths, and this must not become an unhandled-rejection crash. - void enqueue(args) - .then((queued) => - queued === false || queued?.kind === 'fallback' ? onNotQueued?.() : undefined - ) - .catch((err) => { - console.warn('Failed to enqueue PR refresh:', err) - }) -} - -type GitHubWorkItemRequestContext = { - repoId: string - repoPath: string - target: GitHubWorkItemRequestTarget -} - -type GitHubWorkItemRequestTarget = - | { kind: 'environment'; environmentId: string; runtimeRepoId: string } - | { kind: 'local' } - -type GitHubWorkItemsListArgs = { - limit: number - query?: string - page?: number - noCache?: true -} - -function settingsForGitHubRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - // Why: local and SSH-owned GitHub lookups run on the desktop client; host focus must not redirect them to the selected runtime. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - -function settingsForGitHubFocusedRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForGitHubRepoOwner(settings, repo) -} - -function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { - const explicitHostId = normalizeExecutionHostId(alias.executionHostId) - if (explicitHostId) { - return explicitHostId - } - const scope = alias.cacheKey.split('::', 1)[0] - return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID -} - -function findRepoForGitHubOwner( - state: Partial>, - repoId: string | undefined, - repoPath: string -): Repo | undefined { - return state.repos - ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) - : undefined -} - -function getGitHubFocusedRepoOwnerHostId( - settings: AppState['settings'], - repo: Pick | undefined -): string { - if (repo?.executionHostId || repo?.connectionId) { - return getRepoExecutionHostId(repo) - } - return getSettingsFocusedExecutionHostId(settings) -} - -function getWorkItemsCacheKeyForOwner( - state: Partial>, - repoId: string, - limit: number, - query: string, - repoPath?: string -): string { - const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') - return workItemsCacheKey( - repoId, - limit, - query, - repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined - ) -} - -function getGitHubWorkItemSourceHostId( - state: AppState, - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): ExecutionHostId | undefined { - if (sourceContext?.provider === 'github') { - return sourceContext.hostId - } - return repo - ? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined) - : undefined -} - -function getGitHubWorkItemSourceCacheScope( - state: AppState, - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): string | undefined { - if (sourceContext?.provider === 'github') { - return getTaskSourceCacheScope(sourceContext) - } - return getGitHubWorkItemSourceHostId(state, repo, sourceContext) -} - -function getGitHubWorkItemSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): AppState['settings'] { - if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] - } - return settingsForGitHubFocusedRepoOwner(settings, repo) -} - -function getGitHubRepoSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): AppState['settings'] { - if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] - } - return settingsForGitHubRepoOwner(settings, repo) -} - -function getGitHubWorkItemRequestContext( - state: AppState, - settings: AppState['settings'], - repoId: string, - repoPath: string, - sourceContext?: TaskSourceContext | null -): GitHubWorkItemRequestContext { - if (sourceContext?.provider === 'github') { - const parsedHost = parseExecutionHostId(sourceContext.hostId) - if (parsedHost?.kind === 'runtime') { - return { - repoId, - repoPath, - target: { - kind: 'environment', - environmentId: parsedHost.environmentId, - runtimeRepoId: sourceContext.repoId ?? repoId - } - } - } - } - const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) - return { - repoId, - repoPath, - target: runtimeRepo - ? { - kind: 'environment', - environmentId: runtimeRepo.target.environmentId, - runtimeRepoId: runtimeRepo.repo.id - } - : { kind: 'local' } - } -} - -function listGitHubWorkItemsForRepo( - context: GitHubWorkItemRequestContext, - args: GitHubWorkItemsListArgs -): Promise>> { - if (context.target.kind === 'environment') { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: context.target.environmentId }, - 'github.listWorkItems', - { - repo: context.target.runtimeRepoId, - ...args - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gh.listWorkItems({ - repoPath: context.repoPath, - repoId: context.repoId, - ...args - }) -} - -function countGitHubWorkItemsForRepo( - context: GitHubWorkItemRequestContext, - args: { query?: string } -): Promise { - if (context.target.kind === 'environment') { - return callRuntimeRpc( - { kind: 'environment', environmentId: context.target.environmentId }, - 'github.countWorkItems', - { - repo: context.target.runtimeRepoId, - ...args - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gh.countWorkItems({ - repoPath: context.repoPath, - repoId: context.repoId, - ...args - }) -} - -function isGitHubUnavailableWorkItemsError(error: unknown): boolean { - // Why: only `runtime_error` came from the GitHub method; other RPC transport failures ("timed out"/"unavailable") must not be blamed on GitHub. - if (error instanceof RuntimeRpcCallError && error.code !== 'runtime_error') { - return false - } - const message = error instanceof Error ? error.message : String(error) - return classifyGitHubUnavailable(message) !== null -} - -export function projectViewCacheKey( - ownerType: GetProjectViewTableArgs['ownerType'], - owner: string, - projectNumber: number, - resolvedViewId: string, - queryOverride?: string, - sourceScope = 'local', - host?: string -): string { - const projectKey = githubProjectIdentityKey({ ownerType, owner, number: projectNumber, host }) - return `github-project:${sourceScope}:${projectKey}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` -} - -function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { - // Why: without `viewId` the resolved cache key isn't known until the IPC returns, so dedup on the input-arg signature instead. - const selector = args.viewId - ? `id:${args.viewId}` - : args.viewNumber !== undefined - ? `num:${args.viewNumber}` - : args.viewName - ? `name:${args.viewName}` - : 'default' - const projectKey = githubProjectIdentityKey({ - ownerType: args.ownerType, - owner: args.owner, - number: args.projectNumber, - host: args.host - }) - return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` -} - -function projectViewSourceScope(settings: AppState['settings']): string { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' -} - -function settingsForProjectViewCacheKey( - settings: AppState['settings'], - cacheKey: string -): Pick, 'activeRuntimeEnvironmentId'> { - const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) - if (runtimeMatch) { - return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } - } - return { ...settings, activeRuntimeEnvironmentId: null } -} - -// Why: reuses the work-item concurrency gate since project-view and work-item fetches share the same gh subprocess budget; separate gates would blow the cap. -const inflightProjectViewRequests = new Map< - string, - { promise: Promise; force: boolean } ->() - -// Why: optimistic field value so the patched row re-renders immediately; best-effort, overwritten by the authoritative payload on next refresh. -function optimisticFieldValueFromMutation( - table: GitHubProjectTable, - fieldId: string, - value: GitHubProjectFieldMutationValue -): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null { - const field = table.selectedView.fields.find((f) => f.id === fieldId) - switch (value.kind) { - case 'single-select': { - if (field?.kind === 'single-select') { - const option = field.options.find((o) => o.id === value.optionId) - if (option) { - return { - kind: 'single-select', - fieldId, - optionId: option.id, - name: option.name, - color: option.color - } - } - } - return { - kind: 'single-select', - fieldId, - optionId: value.optionId, - name: '', - color: '' - } - } - case 'iteration': { - if (field?.kind === 'iteration') { - const iteration = field.iterations.find((i) => i.id === value.iterationId) - if (iteration) { - return { - kind: 'iteration', - fieldId, - iterationId: iteration.id, - title: iteration.title, - startDate: iteration.startDate, - duration: iteration.duration - } - } - } - return { - kind: 'iteration', - fieldId, - iterationId: value.iterationId, - title: '', - startDate: '', - duration: 0 - } - } - case 'text': - return { kind: 'text', fieldId, text: value.text } - case 'number': - return { kind: 'number', fieldId, number: value.number } - case 'date': - return { kind: 'date', fieldId, date: value.date } - } - return null -} - -function applyRowPatch( - set: (fn: (s: AppState) => Partial) => void, - cacheKey: string, - rowId: string, - nextRow: GitHubProjectRow -): void { - set((s) => { - const entry = s.projectViewCache[cacheKey] - if (!entry?.data) { - return {} - } - const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return {} - } - const rows = [...entry.data.rows] - rows[rowIndex] = nextRow - return { - projectViewCache: { - ...s.projectViewCache, - [cacheKey]: { - ...entry, - data: { ...entry.data, rows } - } - } - } - }) -} - -function rollbackRowIfPresent( - set: (fn: (s: AppState) => Partial) => void, - get: () => AppState, - cacheKey: string, - rowId: string, - previousRow: GitHubProjectRow -): void { - // Why: skip rollback when the entry moved (rapid project switch) or the row is gone, else stale data would surface in the newly selected project. - const entry = get().projectViewCache[cacheKey] - if (!entry?.data) { - return - } - const stillPresent = entry.data.rows.some((r) => r.id === rowId) - if (!stillPresent) { - return - } - applyRowPatch(set, cacheKey, rowId, previousRow) -} - -function parseSlugAndNumber( - row: GitHubProjectRow -): { owner: string; repo: string; number: number } | null { - if (!row.content.repository || row.content.number == null) { - return null - } - const parts = row.content.repository.split('/') - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null - } - return { owner: parts[0], repo: parts[1], number: row.content.number } -} - -export type WorkItemsCacheSources = { - issues: GitHubOwnerRepo | null - prs: GitHubOwnerRepo | null - /** Raw origin remote (if any); required-nullable so the selector can distinguish it from the effective PR source. */ - originCandidate: GitHubOwnerRepo | null - /** Raw upstream remote (if any); required-nullable (like `issues`/`prs`) so consumers branch on null-vs-value, not a three-state. */ - upstreamCandidate: GitHubOwnerRepo | null -} - -// Why: stamp the slug on the error so banner copy stays correct even when the error outlives the entry's `sources` field. -export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } - -export type CacheEntry = { - data: T | null - fetchedAt: number - headSha?: string - /** Resolved issue/PR owner/repo slugs; set only on `fetchWorkItems` entries (single-item PR/issue caches don't carry sources). */ - sources?: WorkItemsCacheSources - /** Per-side classified error; on partial success `data` keeps the good side and the failing side is recorded here so banner + list render together. */ - error?: WorkItemsCacheError - /** True when the resolver fell back to origin because the preferred `'upstream'` remote is gone; typed `?: true` (never `false`) to encode "present iff fell-back". */ - issueSourceFellBack?: true -} - -type FetchOptions = { - force?: boolean - noCache?: boolean - requireComplete?: boolean - allowStaleFallback?: boolean - sourceContext?: TaskSourceContext | null -} - -type RepoScopedFetchOptions = FetchOptions & { - repoId?: string -} - -export type PRRefreshState = { - status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error' - reason: GitHubPRRefreshReason - updatedAt: number - pausedUntil?: number - message?: string - // Why: classified errors drive stable copy without exposing raw upstream messages. - errorType?: PRRefreshErrorType - skippedReason?: GitHubPRRefreshSkippedReason - nextAutoRetryAt?: number - retryDisabledUntil?: number -} - -export type PRRefreshStateClearToken = { - sequence: number - status: PRRefreshState['status'] - updatedAt: number -} - -const PR_REFRESH_ACTIVE_STALE_MS = 120_000 -const PR_REFRESH_PAUSED_GRACE_MS = 5_000 - -function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed) -const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently -const EMPTY_CHECKS_CACHE_TTL = 10_000 -// Why: the work-item list is a browse surface, not a source of truth, so 60s staleness is fine (SWR keeps it current). -const WORK_ITEMS_CACHE_TTL = 60_000 -// GitHub's Search API serves the page that starts within its first 1000 results; -// the next page 422s even when the final reachable page crosses the boundary. -const GITHUB_SEARCH_RESULT_WINDOW = 1000 -// Why: long-lived (matches repos.ts) so the user has time to read + act on persist failures before the toast vanishes. -const ERROR_TOAST_DURATION = 60_000 - -const inflightPRRequests = new Map< - string, - { promise: Promise; force: boolean; generation: number; lookupHintKey: string } ->() -const inflightIssueRequests = new Map>() -type InflightChecks = { - promise: Promise - force: boolean - noCache: boolean -} -const inflightChecksRequests = new Map() -const inflightCommentsRequests = new Map>() -type InflightWorkItems = { - promise: Promise - force: boolean - noCache: boolean - requireComplete: boolean -} -const inflightWorkItemsRequests = new Map() -const prRequestGenerations = new Map() -const prRefreshStartedHostedReviewEntries = new Map< - string, - AppState['hostedReviewCache'][string] | undefined ->() -const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128 - -/** @internal - exposed for leak-regression tests only */ -export function _getGitHubPRRequestGenerationCountForTest(): number { - return prRequestGenerations.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _getGitHubPRRefreshStartedEntryCountForTest(): number { - return prRefreshStartedHostedReviewEntries.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _clearGitHubPRRefreshStartedEntriesForTest(): void { - prRefreshStartedHostedReviewEntries.clear() -} - -// Why: cap fan-out at the renderer boundary (main-side gate is behind IPC, can't stop a stampede in time); 8 balances responsiveness vs gh rate limits. -const WORK_ITEM_FETCH_CONCURRENCY = 8 -let workItemFetchInFlight = 0 -const workItemFetchWaiters: (() => void)[] = [] - -async function acquireWorkItemSlot(): Promise { - if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) { - workItemFetchInFlight += 1 - return - } - await new Promise((resolve) => workItemFetchWaiters.push(resolve)) - // Why: the resolver already claimed the slot on our behalf, so don't re-increment here. -} - -function releaseWorkItemSlot(): void { - const next = workItemFetchWaiters.shift() - if (next) { - // Hand the slot off directly (net count unchanged) so a third caller can't race into the cap between decrement and resolve. - next() - return - } - workItemFetchInFlight -= 1 -} - -export function workItemsCacheKey( - repoId: string, - limit: number, - query: string, - executionHostId?: string | null -): string { - const scope = executionHostId?.trim() ?? '' - const hostId = normalizeExecutionHostId(scope) - const owner = `${repoId}::${limit}::${query}` - if (hostId) { - return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner - } - return scope ? `${scope}::${owner}` : owner -} - -function workItemsInflightRequestKey( - cacheKey: string, - target: GitHubWorkItemRequestTarget -): string { - const targetPart = - target.kind === 'environment' ? `env:${target.environmentId}:${target.runtimeRepoId}` : 'local' - return `${cacheKey}::${targetPart}` -} - -export function issueCacheKey( - repoPath: string, - repoId: string | undefined, - issueNumber: number | string, - settings?: Pick | null, - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubRepoCacheKey( - repoPath, - repoId, - String(issueNumber), - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function runtimeScopedRepoCacheKey( - repoPath: string, - repoId: string | undefined, - suffix: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubRepoCacheKey( - repoPath, - repoId, - suffix, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function sourceScopedRepoCacheKey( - repoPath: string, - repoId: string | undefined, - suffix: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - sourceContext?: TaskSourceContext | null, - hasRepoOwner = false -): string { - if (sourceContext?.provider === 'github') { - return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` - } - return runtimeScopedRepoCacheKey( - repoPath, - repoId, - suffix, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function prCacheKey( - repoPath: string, - repoId: string | undefined, - branch: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubPRCacheKey( - repoPath, - repoId, - branch, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { - const prefixes = [`${repoId}::`] - if (repoPath && repoPath !== repoId) { - prefixes.push(`${repoPath}::`) - } - return prefixes -} - -function matchesRepoCacheKey(key: string, prefixes: readonly string[]): boolean { - return prefixes.some((prefix) => key.startsWith(prefix)) -} - -function clearInflightWorkItemsForRepo(repoId: string, repoPath?: string): void { - const prefixes = repoCacheKeyPrefixes(repoId, repoPath) - for (const key of Array.from(inflightWorkItemsRequests.keys())) { - if (matchesRepoCacheKey(key, prefixes)) { - inflightWorkItemsRequests.delete(key) - } - } -} - -function evictRepoCacheEntries( - cache: Record>, - prefixes: readonly string[] -): { cache: Record>; evicted: boolean } { - let next: Record> | null = null - for (const key of Object.keys(cache)) { - if (!matchesRepoCacheKey(key, prefixes)) { - continue - } - if (!next) { - next = { ...cache } - } - delete next[key] - } - return next ? { cache: next, evicted: true } : { cache, evicted: false } -} - -function normalizedRepoIdentity(repo: GitHubOwnerRepo): string { - return githubRepoIdentityKey(repo) -} - -function normalizedHeadSha(headSha?: string): string | null { - const trimmed = headSha?.trim() - return trimmed ? trimmed.toLowerCase() : null -} - -export function prChecksCacheSuffix( - prNumber: number, - prRepo?: GitHubOwnerRepo | null, - headSha?: string -): string { - const headSuffix = normalizedHeadSha(headSha) - const base = prRepo - ? `pr-checks::${normalizedRepoIdentity(prRepo)}::${prNumber}` - : `pr-checks::${prNumber}` - return headSuffix ? `${base}::head::${headSuffix}` : base -} - -export function prCommentsCacheSuffix(prNumber: number, prRepo?: GitHubOwnerRepo | null): string { - if (!prRepo) { - return `pr-comments::${prNumber}` - } - return `pr-comments::${normalizedRepoIdentity(prRepo)}::${prNumber}` -} - -function commentTimestamp(comment: PRComment): number { - const timestamp = new Date(comment.createdAt).getTime() - return Number.isFinite(timestamp) ? timestamp : 0 -} - -export function mergePRCommentIntoList( - comments: readonly PRComment[] | null | undefined, - incoming: PRComment -): PRComment[] { - const byId = new Map() - for (const comment of comments ?? []) { - byId.set(comment.id, comment) - } - const previous = byId.get(incoming.id) - byId.set(incoming.id, { - ...previous, - ...incoming, - threadId: incoming.threadId ?? previous?.threadId, - path: incoming.path ?? previous?.path, - line: incoming.line ?? previous?.line, - startLine: incoming.startLine ?? previous?.startLine, - isResolved: incoming.isResolved ?? previous?.isResolved, - isOutdated: incoming.isOutdated ?? previous?.isOutdated - }) - return Array.from(byId.values()).sort((a, b) => commentTimestamp(a) - commentTimestamp(b)) -} - -function hasUsableCommentPayload(result: GitHubCommentResult): result is { - ok: true - comment: PRComment -} { - return ( - result.ok && - typeof result.comment?.id === 'number' && - Number.isSafeInteger(result.comment.id) && - result.comment.id > 0 && - typeof result.comment.body === 'string' && - typeof result.comment.createdAt === 'string' - ) -} - -// Why: bound cache growth across many repos/branches over a long session; 500 is above realistic use. -const MAX_CACHE_ENTRIES = 500 -type GitHubPRFallbackSource = NonNullable - -function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < ttl -} - -function getPRChecksCacheTtl(entry: CacheEntry | undefined): number { - return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL -} - -function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null -} - -type WorktreeLookupEntry = { - first: Worktree - unique: Worktree | null -} - -type WorktreeLookupIndex = { - byId: Map - repoHostIdsByRepoId: Map> -} - -const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} -const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] - -function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { - const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { - for (const worktree of worktrees) { - const worktreeId = worktree.id - const existing = byId.get(worktreeId) - if (existing) { - existing.unique = null - } else { - byId.set(worktreeId, { first: worktree, unique: worktree }) - } - } - } - - const repoHostIdsByRepoId = new Map>() - for (const repo of state.repos ?? []) { - let hostIds = repoHostIdsByRepoId.get(repo.id) - if (!hostIds) { - hostIds = new Set() - repoHostIdsByRepoId.set(repo.id, hostIds) - } - hostIds.add(getRepoExecutionHostId(repo)) - } - return { byId, repoHostIdsByRepoId } -} - -// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. -const worktreeLookupIndexes = new WeakMap< - AppState['worktreesByRepo'], - { repos: AppState['repos']; index: WorktreeLookupIndex } ->() - -function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { - const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO - const repos = state.repos ?? EMPTY_WORKTREE_REPOS - const cached = worktreeLookupIndexes.get(worktreesByRepo) - if (cached && cached.repos === repos) { - return cached.index - } - const index = buildWorktreeLookupIndex(state) - worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) - return index -} - -function findUniqueWorktreeById( - state: AppState, - worktreeId: string, - executionHostId?: string, - lookupIndex = getWorktreeLookupIndex(state) -): Worktree | null { - const match = lookupIndex.byId.get(worktreeId)?.unique ?? null - // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. - if (!match || executionHostId === undefined) { - return match - } - const expectedHostId = normalizeExecutionHostId(executionHostId) ?? LOCAL_EXECUTION_HOST_ID - const explicitWorktreeHostId = normalizeExecutionHostId(match.hostId) - if (explicitWorktreeHostId) { - return explicitWorktreeHostId === expectedHostId ? match : null - } - const repoHostIds = lookupIndex.repoHostIdsByRepoId.get(match.repoId) - // Pre-host persisted rows are safe only when their repo has one unambiguous owner. - if (!repoHostIds || repoHostIds.size !== 1 || !repoHostIds.has(expectedHostId)) { - return null - } - return match -} - -function isStaleExactLinkedPRLookup( - state: AppState, - worktreeId: string | undefined, - linkedPRNumber: number | null | undefined, - lookupIndex?: WorktreeLookupIndex -): boolean { - if (!worktreeId || linkedPRNumber == null) { - return false - } - const worktree = lookupIndex - ? (lookupIndex.byId.get(worktreeId)?.first ?? null) - : findWorktreeById(state, worktreeId) - return worktree?.linkedPR !== linkedPRNumber -} - -function shouldClearDivergedLinkedMergedPR(args: { - pr: PRInfo | null - linkedPRNumber: number | null - requestHeadOid: string | null -}): boolean { - const { pr, linkedPRNumber, requestHeadOid } = args - return ( - linkedPRNumber != null && - requestHeadOid !== null && - pr?.number === linkedPRNumber && - pr.state === 'merged' && - // Head-scoped: clear only the worktree whose head diverged, so a PR-number-coalesced broadcast can't clear a sibling still on the PR's line of work. - pr.headDivergedFromMergedPRAtOid === requestHeadOid && - pr.headSha !== requestHeadOid && - pr.confirmedContainedHeadOid !== requestHeadOid - ) -} - -function shouldApplyDivergedLinkedPRClear(args: { - worktree: Pick | undefined - linkedPRNumber: number - branch: string - requestHeadOid: string | null -}): boolean { - const { worktree, linkedPRNumber, branch, requestHeadOid } = args - return ( - Boolean(worktree) && - requestHeadOid !== null && - worktree?.linkedPR === linkedPRNumber && - worktree.branch.replace(/^refs\/heads\//, '') === branch && - worktree.head === requestHeadOid && - worktree.isBare !== true && - worktree.isArchived !== true - ) -} - -// Why: a linked PR is branch-scoped; it's stale once the worktree switched branches with neither push target nor HEAD at the PR head, else Checks stays pinned to the old branch's PR. -export function shouldClearBranchMismatchedLinkedOpenPR(args: { - pr: PRInfo | null - linkedPRNumber: number | null - branch: string - requestHeadOid: string | null - pushTargetBranch: string | null -}): boolean { - const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args - const headRefName = pr?.headRefName?.trim() ?? '' - const currentBranch = branch.replace(/^refs\/heads\//, '').trim() - return ( - linkedPRNumber != null && - pr?.number === linkedPRNumber && - // Draft reviews are open PRs too; don't let their distinct renderer state leave a stale durable link wedged after a branch switch. - (pr.state === 'open' || pr.state === 'draft') && - requestHeadOid !== null && - headRefName !== '' && - currentBranch !== '' && - headRefName !== currentBranch && - (pushTargetBranch === null || pushTargetBranch !== headRefName) && - // A worktree parked on the PR's head commit is the same line of work (e.g. renamed local branch); keep the link. - !(pr.headSha != null && pr.headSha === requestHeadOid) - ) -} - -function shouldApplyBranchMismatchedLinkedPRClear(args: { - worktree: Pick | undefined - linkedPRNumber: number - branch: string - requestHeadOid: string | null -}): boolean { - const { worktree, linkedPRNumber, branch, requestHeadOid } = args - return ( - Boolean(worktree) && - requestHeadOid !== null && - worktree?.linkedPR === linkedPRNumber && - // Branch-scoped: clear only while still on the branch the mismatch was computed against; a newer switch re-validates. - worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') && - worktree.head === requestHeadOid && - worktree.isBare !== true && - worktree.isArchived !== true - ) -} - -function buildPRRefreshCandidate( - state: AppState, - worktree: Worktree, - repoPath?: string, - repoOverride?: Repo -): GitHubPRRefreshCandidate | null { - const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) - if (!repo) { - return null - } - if (isMacAppDataPath(repoPath ?? repo.path)) { - return null - } - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const cacheKey = prCacheKey( - repoPath ?? repo.path, - repo.id, - branch, - settingsForGitHubRepoOwner(state.settings, repo), - repo.connectionId, - repo.executionHostId, - true - ) - const cachedPR = state.prCache[cacheKey]?.data ?? null - const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( - state, - repoPath ?? repo.path, - repo.id, - branch, - repo.connectionId, - repo.executionHostId, - true - ) - const cachedFallbackPRNumber = cachedPR?.number ?? null - // Why: a merged PR is a valid fallback only while the worktree sits on its head or a confirmed-contained commit — else the branch moved on. - const cachedMergedPRMovedPastHead = - worktree.linkedPR == null && - cachedPR?.state === 'merged' && - cachedPR.headSha !== worktree.head && - cachedPR.confirmedContainedHeadOid !== worktree.head - const fallbackPRNumber = - worktree.linkedPR == null && !cachedMergedPRMovedPastHead - ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) - : null - const fallbackPRSource: GitHubPRFallbackSource | null = - worktree.linkedPR != null || fallbackPRNumber == null - ? null - : cachedFallbackPRNumber != null - ? 'pr-cache' - : 'hosted-review' - const sshStatus = repo.connectionId - ? state.sshConnectionStates.get(repo.connectionId)?.status - : null - return { - repoId: repo.id, - repoPath: repoPath ?? repo.path, - repoKind: repo.kind ?? 'git', - branch, - cacheKey, - worktreeId: worktree.id, - currentHeadOid: worktree.head ?? null, - // Why: persisted linked PR metadata is exact; PR cache numbers are only fallback hints after branch-lookup misses. - linkedPRNumber: worktree.linkedPR ?? null, - fallbackPRNumber, - fallbackPRSource, - isBare: worktree.isBare, - isArchived: worktree.isArchived, - connectionId: repo.connectionId ?? null, - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId - ? sshStatus === 'connected' - ? 'connected' - : 'disconnected' - : 'unknown', - cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null, - cachedHasPR: cachedPR ? true : state.prCache[cacheKey] ? false : null, - cachedPRState: cachedPR?.state ?? null, - cachedChecksStatus: cachedPR?.checksStatus ?? null, - cachedMergeable: cachedPR?.mergeable ?? null, - cachedMergeStateStatus: cachedPR?.mergeStateStatus ?? null - } -} - -function githubHostedReviewFallbackPRNumber( - state: AppState, - repoPath: string, - repoId: string | undefined, - branch: string, - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): number | null { - const hostedReviewCacheKey = getHostedReviewCacheKey( - repoPath, - branch, - state.settings, - repoId, - connectionId, - executionHostId, - hasRepoOwner - ) - const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data - return hostedReview?.provider === 'github' ? hostedReview.number : null -} - -function shouldClearHostedReviewForNoGitHubPR( - entry: AppState['hostedReviewCache'][string] | undefined -): boolean { - // Why: a GitHub-only miss must not suppress GitLab/other hosted-review discovery via provider-neutral branch misses. - if (!entry) { - return false - } - if (entry.data?.provider === 'github') { - return true - } - return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) -} - -function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { - return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false -} - -function prLookupHintKey(linkedPRNumber: number | null, fallbackPRNumber: number | null): string { - if (linkedPRNumber !== null) { - return `linked:${linkedPRNumber}` - } - return fallbackPRNumber !== null ? `fallback:${fallbackPRNumber}` : '' -} - -function linkedReviewHintKeyForNoGitHubPR( - entry: AppState['hostedReviewCache'][string] | undefined -): string | undefined { - if (entry?.data?.provider === 'github') { - return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) - ? entry.linkedReviewHintKey - : linkedReviewHintKey({ linkedGitHubPR: entry.data.number }) - } - return entry?.linkedReviewHintKey -} - -function hasNewerHostedReviewCacheEntry( - cache: AppState['hostedReviewCache'], - cacheKey: string, - requestStartedAt: number, - requestStartedEntry: AppState['hostedReviewCache'][string] | undefined -): boolean { - const entry = cache[cacheKey] - return ( - entry !== undefined && - (entry.fetchedAt > requestStartedAt || - (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) - ) -} - -function syncHostedReviewCacheFromGitHubPRResult(args: { - cache: AppState['hostedReviewCache'] - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - preserveExistingPRForFallbackMiss?: boolean - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] -}): { cache: AppState['hostedReviewCache']; accepted: boolean } { - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - if ( - args.requestStartedAt !== undefined && - hasNewerHostedReviewCacheEntry( - args.cache, - hostedReviewCacheKey, - args.requestStartedAt, - args.requestStartedEntry - ) - ) { - return { cache: args.cache, accepted: false } - } - const hostedReviewEntry = args.cache[hostedReviewCacheKey] - if ( - args.requestStartedAt === undefined && - hostedReviewEntry !== undefined && - hostedReviewEntry.fetchedAt >= args.fetchedAt - ) { - return { cache: args.cache, accepted: false } - } - if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') { - return { cache: args.cache, accepted: false } - } - // Why: a hosted-review row survives an authoritative miss only when the paired PR cache preserves a terminal, head-current PR. - if ( - !args.pr && - args.linkedPRNumber == null && - args.fallbackPRNumber != null && - args.fallbackPRSource !== 'hosted-review' && - hostedReviewEntry?.data?.provider === 'github' && - hostedReviewEntry.data.number === args.fallbackPRNumber && - args.preserveExistingPRForFallbackMiss === true && - canPreserveReviewForFallbackMiss(hostedReviewEntry.data.state) - ) { - return { cache: args.cache, accepted: false } - } - if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) { - return { cache: args.cache, accepted: hostedReviewEntry?.data == null } - } - // Why: hosted-review fallbacks may be stale exact links; inherit branch provenance only when already proven. - const branchLookupGitHubPRNumber = - args.pr && - args.linkedPRNumber == null && - (args.fallbackPRSource !== 'hosted-review' || - args.pr.number !== args.fallbackPRNumber || - hostedReviewEntry?.branchLookupGitHubPRNumber === args.pr.number) - ? args.pr.number - : undefined - return { - cache: { - ...args.cache, - [hostedReviewCacheKey]: { - data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null, - fetchedAt: args.fetchedAt, - linkedReviewHintKey: args.pr - ? linkedReviewHintKey({ linkedGitHubPR: args.pr.number }) - : linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry), - ...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {}) - } - }, - accepted: true - } -} - -function shouldWritePRCacheForHostedReviewSync(args: { - hostedReviewSyncAccepted: boolean - hostedReviewEntry: AppState['hostedReviewCache'][string] | undefined - pr: PRInfo | null - linkedPRNumber?: number | null - fallbackPRNumber?: number | null -}): boolean { - // Why: grouping reads prCache while cards read hostedReviewCache; keep them from drifting when a result is rejected for the card. - if (args.hostedReviewSyncAccepted) { - return true - } - const exactPRNumber = args.linkedPRNumber ?? args.fallbackPRNumber ?? null - return ( - exactPRNumber !== null && - args.pr?.number === exactPRNumber && - args.hostedReviewEntry?.data?.provider === 'github' && - args.hostedReviewEntry.data.number === exactPRNumber - ) -} - -function canPreserveReviewForFallbackMiss(state: PRInfo['state'] | undefined): boolean { - return state === 'closed' || state === 'merged' -} - -function shouldPreserveExistingPRForFallbackMiss(args: { - currentPR: PRInfo | null | undefined - nextPR: PRInfo | null - state: AppState - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null -}): boolean { - if ( - args.nextPR !== null || - args.linkedPRNumber != null || - args.currentPR?.state !== 'merged' || - typeof args.currentPR.headSha !== 'string' || - args.currentPR.headSha.length === 0 - ) { - return false - } - // Why: gate the global worktree scan so batched refresh aliases don't multiply full scans (common paths don't need it). - const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null - const worktreeHead = worktree?.head - // Why: keep a merged PR only when its cached head matches the worktree head — exactly or a confirmed-contained commit. - const preservesMergedPRForCurrentHead = - typeof worktreeHead === 'string' && - worktreeHead.length > 0 && - (args.currentPR.headSha === worktreeHead || - args.currentPR.confirmedContainedHeadOid === worktreeHead) - - return preservesMergedPRForCurrentHead -} - -function applyPRCacheResult( - cache: AppState['prCache'], - cacheKey: string, - pr: PRInfo | null, - fetchedAt: number, - accepted: boolean, - preserveExisting: boolean -): AppState['prCache'] { - if (preserveExisting) { - return cache - } - if (accepted) { - return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt }) - } - if (!cache[cacheKey]) { - return cache - } - const next = { ...cache } - delete next[cacheKey] - return next -} - -function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string { - return `${sequence}::${cacheKey}` -} - -function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void { - if (sequence !== undefined && sequence > 0) { - prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey)) - } -} - -function setPRRefreshStartedHostedReviewEntry( - key: string, - entry: AppState['hostedReviewCache'][string] | undefined -): void { - if (entry === undefined) { - prRefreshStartedHostedReviewEntries.delete(key) - return - } - prRefreshStartedHostedReviewEntries.delete(key) - prRefreshStartedHostedReviewEntries.set(key, entry) - while (prRefreshStartedHostedReviewEntries.size > PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX) { - const oldest = prRefreshStartedHostedReviewEntries.keys().next() - if (oldest.done) { - return - } - prRefreshStartedHostedReviewEntries.delete(oldest.value) - } -} - -function setGitHubPRResultCaches( - state: AppState, - args: { - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] - } -): Partial { - const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ - currentPR: state.prCache[args.prCacheKey]?.data, - nextPR: args.pr, - state, - worktreeId: args.worktreeId, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource - }) - const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ - cache: state.hostedReviewCache, - repoPath: args.repoPath, - branch: args.branch, - settings: args.settings, - repoId: args.repoId, - connectionId: args.connectionId, - executionHostId: args.executionHostId, - hasRepoOwner: args.hasRepoOwner, - pr: args.pr, - fetchedAt: args.fetchedAt, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource, - preserveExistingPRForFallbackMiss, - requestStartedAt: args.requestStartedAt, - requestStartedEntry: args.requestStartedEntry - }) - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - const nextPRCache = applyPRCacheResult( - state.prCache, - args.prCacheKey, - args.pr, - args.fetchedAt, - shouldWritePRCacheForHostedReviewSync({ - hostedReviewSyncAccepted: hostedReviewSync.accepted, - hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey], - pr: args.pr, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber - }), - preserveExistingPRForFallbackMiss - ) - return { - ...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }), - ...(hostedReviewSync.cache === state.hostedReviewCache - ? {} - : { hostedReviewCache: hostedReviewSync.cache }) - } -} - -function applyGitHubPRResultToCaches(args: { - prCache: AppState['prCache'] - hostedReviewCache: AppState['hostedReviewCache'] - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - state: AppState - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] -}): { - prCache: AppState['prCache'] - hostedReviewCache: AppState['hostedReviewCache'] -} { - const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ - currentPR: args.prCache[args.prCacheKey]?.data, - nextPR: args.pr, - state: args.state, - worktreeId: args.worktreeId, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource - }) - const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ - cache: args.hostedReviewCache, - repoPath: args.repoPath, - branch: args.branch, - settings: args.settings, - repoId: args.repoId, - connectionId: args.connectionId, - executionHostId: args.executionHostId, - hasRepoOwner: args.hasRepoOwner, - pr: args.pr, - fetchedAt: args.fetchedAt, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource, - preserveExistingPRForFallbackMiss, - requestStartedAt: args.requestStartedAt, - requestStartedEntry: args.requestStartedEntry - }) - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - return { - prCache: applyPRCacheResult( - args.prCache, - args.prCacheKey, - args.pr, - args.fetchedAt, - shouldWritePRCacheForHostedReviewSync({ - hostedReviewSyncAccepted: hostedReviewSync.accepted, - hostedReviewEntry: args.hostedReviewCache[hostedReviewCacheKey], - pr: args.pr, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber - }), - preserveExistingPRForFallbackMiss - ), - hostedReviewCache: hostedReviewSync.cache - } -} - -/** Evicts the oldest entries when over max size; returns a pruned copy, or the original reference if nothing was evicted. */ -function evictStaleEntries( - cache: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - const keys = Object.keys(cache) - if (keys.length <= maxEntries) { - return cache - } - const sorted = keys - .map((k) => ({ key: k, fetchedAt: cache[k].fetchedAt })) - .sort((a, b) => b.fetchedAt - a.fetchedAt) - const keep = new Set(sorted.slice(0, maxEntries).map((e) => e.key)) - const pruned: Record = {} - for (const k of keep) { - pruned[k] = cache[k] - } - return pruned -} - -function withBoundedCacheEntry( - cache: Record, - key: string, - entry: T -): Record { - return evictStaleEntries({ ...cache, [key]: entry }) -} - -// Why: prRefresh* maps have no `fetchedAt` to sort by, so bound them by insertion order (oldest-touched evicted first; an evicted long-idle branch restarts clean). -function capRecordByInsertionOrder( - record: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - const keys = Object.keys(record) - if (keys.length <= maxEntries) { - return record - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = record[key] - } - return capped -} - -function capPrRefreshSequences( - sequences: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - return capRecordByInsertionOrder(sequences, maxEntries) -} - -// Why: backs visible status pills, so evict *settled* (error/skipped) before active entries and never drop an in-progress indicator; evicted entries self-heal on next refresh. -const MAX_PR_REFRESH_STATE_ENTRIES = 2000 -const SETTLED_PR_REFRESH_STATUSES = new Set(['error', 'skipped']) -const ACTIVE_PR_REFRESH_STATUSES = new Set([ - 'queued', - 'in-flight', - 'paused' -]) - -function isPRRefreshStateExpired(state: PRRefreshState, now: number): boolean { - const expiryAt = getGitHubPRRefreshStateExpiryAt(state) - return expiryAt !== null && now > expiryAt -} - -/** Captures the exact refresh snapshot a later timeout or request is allowed to clear. */ -export function buildGitHubPRRefreshStateClearToken( - state: PRRefreshState | undefined, - sequences: Record, - cacheKey: string -): PRRefreshStateClearToken | null { - if (!state) { - return null - } - return { - sequence: sequences[cacheKey] ?? 0, - status: state.status, - updatedAt: state.updatedAt - } -} - -/** Returns the wall-clock expiry for transient refresh states; settled states persist. */ -export function getGitHubPRRefreshStateExpiryAt(state: PRRefreshState | undefined): number | null { - if (!state) { - return null - } - if (state.status === 'queued' || state.status === 'in-flight') { - return Number.isFinite(state.updatedAt) ? state.updatedAt + PR_REFRESH_ACTIVE_STALE_MS : 0 - } - if (state.status === 'paused') { - return Number.isFinite(state.pausedUntil) - ? (state.pausedUntil ?? 0) + PR_REFRESH_PAUSED_GRACE_MS - : 0 - } - return null -} - -function isExpiredActivePRRefreshState(state: PRRefreshState, now: number): boolean { - return ACTIVE_PR_REFRESH_STATUSES.has(state.status) && isPRRefreshStateExpired(state, now) -} - -/** Reads refresh state for UI selectors while hiding stale active entries from view. */ -export function getEffectiveGitHubPRRefreshState( - states: Record, - cacheKey: string, - now = Date.now() -): PRRefreshState | undefined { - const state = states[cacheKey] - if (!state || isExpiredActivePRRefreshState(state, now)) { - return undefined - } - return state -} - -function pruneExpiredPRRefreshStates( - states: Record, - now = Date.now() -): Record { - let next: Record | null = null - for (const [cacheKey, state] of Object.entries(states)) { - if (!isExpiredActivePRRefreshState(state, now)) { - continue - } - if (!next) { - next = { ...states } - } - delete next[cacheKey] - } - return next ?? states -} - -function capPrRefreshStates( - states: Record, - maxEntries = MAX_PR_REFRESH_STATE_ENTRIES -): Record { - const keys = Object.keys(states) - let toEvict = keys.length - maxEntries - if (toEvict <= 0) { - return states - } - const evicted = new Set() - // First pass: evict oldest settled (error/skipped) entries. - for (const key of keys) { - if (toEvict === 0) { - break - } - if (SETTLED_PR_REFRESH_STATUSES.has(states[key].status)) { - evicted.add(key) - toEvict-- - } - } - // Last resort: evict oldest remaining keys to enforce the hard bound. - for (const key of keys) { - if (toEvict === 0) { - break - } - if (!evicted.has(key)) { - evicted.add(key) - toEvict-- - } - } - const capped: Record = {} - for (const key of keys) { - if (!evicted.has(key)) { - capped[key] = states[key] - } - } - return capped -} - -function shouldRefreshIssueDecorations(state: AppState): boolean { - return (state.worktreeCardProperties ?? []).includes('issue') -} - -let saveTimer: ReturnType | null = null - -function debouncedSaveCache(state: AppState): void { - if (saveTimer) { - clearTimeout(saveTimer) - } - saveTimer = setTimeout(() => { - saveTimer = null - window.api.cache.setGitHub({ - cache: { - pr: state.prCache, - issue: state.issueCache - } - }) - }, 1000) // Save at most once per second -} - -export type GitHubSlice = { - prCache: Record> - issueCache: Record> - checksCache: Record> - commentsCache: Record> - prRefreshSequences: Record - prRefreshStates: Record - prVisibleRefreshGeneration: number - // Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results. - workItemsCache: Record> - fetchPRForBranch: ( - repoPath: string, - branch: string, - options?: RepoScopedFetchOptions & { - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - } - ) => Promise - fetchIssue: ( - repoPath: string, - number: number, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRChecks: ( - repoPath: string, - prNumber: number, - branch?: string, - headSha?: string, - prRepo?: GitHubOwnerRepo | null, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRCheckDetails: ( - repoPath: string, - args: { - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRComments: ( - repoPath: string, - prNumber: number, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - addPRConversationComment: ( - repoPath: string, - prNumber: number, - body: string, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - addPRReviewCommentReply: ( - repoPath: string, - prNumber: number, - commentId: number, - body: string, - options?: RepoScopedFetchOptions & { - prRepo?: GitHubOwnerRepo | null - threadId?: string - path?: string - line?: number - } - ) => Promise - setPRCommentReaction: ( - repoPath: string, - prNumber: number, - reactionSubjectId: string, - content: GitHubReactionContent, - reacted: boolean, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - resolveReviewThread: ( - repoPath: string, - prNumber: number, - threadId: string, - resolve: boolean, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - initGitHubCache: () => Promise - refreshAllGitHub: () => void - refreshGitHubForWorktree: (worktreeId: string) => void - refreshGitHubForWorktreeIfStale: (worktreeId: string) => void - enqueueGitHubPRRefresh: ( - worktreeId: string, - reason: GitHubPRRefreshReason, - priority?: number - ) => void - reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void - bumpGitHubPRVisibleRefreshGeneration: () => void - applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void - getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined - expireGitHubPRRefreshState: ( - cacheKey: string, - token: PRRefreshStateClearToken, - now?: number - ) => void - /** SWR: returns cached work items immediately (null if none) and fires a background refresh when stale. */ - getCachedWorkItems: ( - repoId: string, - limit: number, - query: string, - repoPath?: string, - sourceContext?: TaskSourceContext | null - ) => readonly GitHubWorkItem[] | null - /** Returns a thin view (sources + error, never items) so it stays a cheap selector without dragging the whole work-item array through the equality check. */ - getWorkItemsSourcesAndError: ( - repoId: string, - limit: number, - query: string, - repoPath?: string - ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } - /** - * Falls back to any `${repoPath}::` cache entry with resolved sources when the primary entry isn't populated yet — sources are repo-level (query-independent), so any sibling is safe to reuse. - * Returns a single stable reference so the dialog can subscribe to just this selector; entries are replaced (not mutated) on write, preserving reference equality between unchanged entries. - */ - getWorkItemsAnySourcesForRepo: ( - repoId: string, - limit: number, - repoPath?: string - ) => WorkItemsCacheSources | null - fetchWorkItems: ( - repoId: string, - repoPath: string, - limit: number, - query: string, - options?: FetchOptions - ) => Promise - /** - * Fan out one work-item query across repos; partial failures don't reject — a repo with no cached fallback increments `failedCount`, but one served stale cache on rejection isn't counted. - * `githubUnavailable`: every selected GitHub source refresh failed because GitHub was unreachable (5xx/network/rate-limit), even if stale cache remains — lets the caller attribute the stale/empty list. - */ - fetchWorkItemsAcrossRepos: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - perRepoLimit: number, - displayLimit: number, - query: string, - options?: FetchOptions - ) => Promise<{ - items: GitHubWorkItem[] - failedCount: number - githubUnavailable: boolean - requestFailureCount?: number - }> - /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ - fetchWorkItemsNextPage: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - perRepoLimit: number, - displayLimit: number, - query: string, - page: number, - options?: Pick - ) => Promise<{ - items: GitHubWorkItem[] - failedCount: number - errorTypes: ClassifiedError['type'][] - }> - /** Count items and derive pages from the largest per-repo result set. */ - countWorkItemsAcrossRepos: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - query: string, - perRepoLimit: number - ) => Promise<{ totalCount: number; totalPages: number }> - /** Fire-and-forget prefetch to warm the cache before the page mounts (hover/focus of the "new workspace" buttons). */ - prefetchWorkItems: ( - repoId: string, - repoPath: string, - limit?: number, - query?: string, - options?: { sourceContext?: TaskSourceContext | null } - ) => void - patchWorkItem: ( - itemId: string, - patch: Partial, - repoId?: string | null, - options?: GitHubPatchWorkItemOptions - ) => void - /** Monotonic counter bumped on issue-source preference flips; subscribers include it in their deps to force a re-fetch, since cache eviction alone won't trip effects keyed on selectedRepos/search/nonce. */ - workItemsInvalidationNonce: number - /** Persist the preference, update the local Repo record, and invalidate all `${repoId}::*` cache keys — not just the primary — so alternate-query lines don't serve stale results after the source flips. */ - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise - evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void - // ── ProjectV2 view cache ───────────────────────────────────────────── - projectViewCache: Record> - fetchProjectViewTable: ( - args: GetProjectViewTableArgs, - options?: FetchOptions - ) => Promise - updateProjectFieldValue: ( - cacheKey: string, - rowId: string, - fieldId: string, - value: GitHubProjectFieldMutationValue - ) => Promise - clearProjectFieldValue: ( - cacheKey: string, - rowId: string, - fieldId: string - ) => Promise - patchProjectIssueOrPr: ( - cacheKey: string, - rowId: string, - updates: ProjectRowContentUpdate - ) => Promise - patchProjectRowIssueType: ( - cacheKey: string, - rowId: string, - issueType: { id: string; name: string; color: string | null; description: string | null } | null - ) => Promise - /** Optimistic, IPC-free patcher for a single `projectViewCache` row's `content`; `patchWorkItem` only walks `workItemsCache` and would leave the Project view stale until the next refresh. */ - patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void -} + ACTIVE_PR_REFRESH_STATUSES, + getEffectiveGitHubPRRefreshState, + isExpiredActivePRRefreshState +} from '../github/pr-refresh-state' +import { evictStaleEntries } from '../github/cache-policy' +import { createProjectActions } from '../github/project-actions' +import { createProjectRowActions } from '../github/project-row-actions' +import { createWorkItemFetchActions } from '../github/work-item-fetch-actions' +import { createWorkItemAggregateActions } from '../github/work-item-aggregate-actions' +import { createWorkItemMutationActions } from '../github/work-item-mutation-actions' +import { createPullRequestActions } from '../github/pull-request-actions' +import { createIssueActions } from '../github/issue-actions' +import { createCheckActions } from '../github/check-actions' +import { createCommentFetchActions } from '../github/comment-fetch-actions' +import { createCommentMutationActions } from '../github/comment-mutation-actions' +import { createConversationCommentActions } from '../github/conversation-comment-actions' +import { createReviewThreadActions } from '../github/review-thread-actions' +import { createStaleWorktreeRefreshActions } from '../github/stale-worktree-refresh-actions' +import { createRefreshRoutingActions } from '../github/refresh-routing-actions' +import { createRefreshEventActions } from '../github/refresh-event-actions' +import { createRefreshSweepActions } from '../github/refresh-sweep-actions' +export type GitHubSlice = GitHubSliceContract export const createGitHubSlice: StateCreator = (set, get) => ({ prCache: {}, @@ -2129,918 +71,22 @@ export const createGitHubSlice: StateCreator = (s }) }, - fetchProjectViewTable: async (args, options) => { - const target = getActiveRuntimeTarget(get().settings) - const sourceScope = projectViewSourceScope(get().settings) - const requestKey = projectViewRequestKey(args, sourceScope) - - // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. - const maybeKnownKey = args.viewId - ? projectViewCacheKey( - args.ownerType, - args.owner, - args.projectNumber, - args.viewId, - args.queryOverride, - sourceScope, - args.host - ) - : null - if (!options?.force && maybeKnownKey) { - const cached = get().projectViewCache[maybeKnownKey] - if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) { - return { ok: true, data: cached.data } - } - } - - const existing = inflightProjectViewRequests.get(requestKey) - if (existing) { - // Why: a forcing caller must not dedupe to a non-forcing in-flight request; wait for it to settle, then issue a fresh forced call (mirrors fetchWorkItems). - if (options?.force && !existing.force) { - await existing.promise.catch(() => {}) - } else { - return existing.promise - } - } - - const request = (async (): Promise => { - await acquireWorkItemSlot() - try { - const envelope = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.viewTable', - args, - { timeoutMs: 60_000 } - ) - : await window.api.gh.getProjectViewTable(args) - if (envelope.ok) { - const table = envelope.data - const key = projectViewCacheKey( - table.project.ownerType, - table.project.owner, - table.project.number, - table.selectedView.id, - args.queryOverride, - sourceScope, - table.project.host - ) - set((s) => ({ - projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { - data: table, - fetchedAt: Date.now() - }) - })) - } else if (maybeKnownKey) { - // Why: only stamp the error when we have a resolved key; without one there's nowhere to write it and the renderer classifies from the envelope. - set((s) => ({ - projectViewCache: withBoundedCacheEntry(s.projectViewCache, maybeKnownKey, { - data: s.projectViewCache[maybeKnownKey]?.data ?? null, - fetchedAt: Date.now(), - error: envelope.error - }) - })) - } - return envelope - } catch (err) { - // Why: the IPC boundary must not throw across the promise — wrap unexpected errors in the classified envelope for a single renderer shape. - console.error('Failed to fetch GitHub project view:', err) - return { - ok: false, - error: { - type: 'unknown', - message: err instanceof Error ? err.message : 'Failed to fetch project view' - } - } - } finally { - releaseWorkItemSlot() - inflightProjectViewRequests.delete(requestKey) - } - })() - - inflightProjectViewRequests.set(requestKey, { - promise: request, - force: Boolean(options?.force) - }) - return request - }, - - updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - // Optimistic patch: build a field value matching the mutation shape. - const nextField = optimisticFieldValueFromMutation(table, fieldId, value) - const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } - if (nextField) { - optimisticFieldValues[fieldId] = nextField - } - const optimisticRow: GitHubProjectRow = { - ...previousRow, - fieldValuesByFieldId: optimisticFieldValues - } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateItemField', - { - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId, - value - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateProjectItemField({ - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId, - value - }) - if (!result.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return result - }, - - clearProjectFieldValue: async (cacheKey, rowId, fieldId) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } - delete optimisticFieldValues[fieldId] - const optimisticRow: GitHubProjectRow = { - ...previousRow, - fieldValuesByFieldId: optimisticFieldValues - } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.clearItemField', - { - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.clearProjectItemField({ - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId - }) - if (!result.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return result - }, - - patchProjectIssueOrPr: async (cacheKey, rowId, updates) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {} - if (!owner || !repo || !number) { - return { - ok: false, - error: { - type: 'validation_error', - message: translate( - 'auto.store.slices.github.87020f6605', - 'Row has no owner/repo/number — cannot patch underlying item' - ) - } - } - } - // Optimistic content patch. - const nextContent = { ...previousRow.content } - if (updates.title !== undefined) { - nextContent.title = updates.title - } - if (updates.body !== undefined) { - nextContent.body = updates.body - } - if (updates.addLabels || updates.removeLabels) { - const next = new Map(nextContent.labels.map((l) => [l.name, l])) - for (const name of updates.addLabels ?? []) { - if (!next.has(name)) { - next.set(name, { name, color: '808080' }) - } - } - for (const name of updates.removeLabels ?? []) { - next.delete(name) - } - nextContent.labels = Array.from(next.values()) - } - if (updates.addAssignees || updates.removeAssignees) { - const next = new Map(nextContent.assignees.map((u) => [u.login, u])) - for (const login of updates.addAssignees ?? []) { - if (!next.has(login)) { - next.set(login, { login, name: null, avatarUrl: null }) - } - } - for (const login of updates.removeAssignees ?? []) { - next.delete(login) - } - nextContent.assignees = Array.from(next.values()) - } - const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - // Why: labels/assignees go through the issue endpoint for both (GitHub PRs are issues for those); title/body split PR→updatePullRequestBySlug vs issue→updateIssueBySlug. - let envelope: GitHubProjectMutationResult = { ok: true } - // Why: slug-only Project rows have no registered Orca repo, so fall back to the view source in the cache key, not the focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) - ) - if ( - previousRow.itemType === 'PULL_REQUEST' && - (updates.title !== undefined || updates.body !== undefined) - ) { - const args = { - owner, - repo, - host: table.project.host, - number, - updates: { - ...(updates.title !== undefined ? { title: updates.title } : {}), - ...(updates.body !== undefined ? { body: updates.body } : {}) - } - } - const prRes = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updatePullRequestBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePullRequestBySlug(args) - if (!prRes.ok) { - envelope = prRes - } - } - if ( - envelope.ok && - (updates.addLabels?.length || - updates.removeLabels?.length || - updates.addAssignees?.length || - updates.removeAssignees?.length || - (previousRow.itemType === 'ISSUE' && - (updates.title !== undefined || updates.body !== undefined))) - ) { - const args = { - owner, - repo, - host: table.project.host, - number, - updates: { - ...(updates.title !== undefined ? { title: updates.title } : {}), - ...(updates.body !== undefined ? { body: updates.body } : {}), - ...(updates.addLabels ? { addLabels: updates.addLabels } : {}), - ...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}), - ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), - ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) - } - } - const issueRes = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateIssueBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssueBySlug(args) - if (!issueRes.ok) { - envelope = issueRes - } - } - if (!envelope.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return envelope - }, - - patchProjectRowIssueType: async (cacheKey, rowId, issueType) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const row = table.rows.find((r) => r.id === rowId) - if (!row) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - if (row.itemType !== 'ISSUE') { - return { - ok: false, - error: { - type: 'validation_error', - message: translate( - 'auto.store.slices.github.83f9b126ad', - 'Issue Type can only be set on Issues.' - ) - } - } - } - const { owner, repo, number } = parseSlugAndNumber(row) ?? {} - if (!owner || !repo || !number) { - return { - ok: false, - error: { - type: 'validation_error', - message: translate('auto.store.slices.github.683a21264b', 'Row has no owner/repo/number.') - } - } - } - const previousRow = row - const optimistic: GitHubProjectRow = { - ...previousRow, - content: { ...previousRow.content, issueType } - } - applyRowPatch(set, cacheKey, rowId, optimistic) - // Why: slug-only Project rows belong to the host that loaded the view, which may differ from the now-focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) - ) - const args = { - owner, - repo, - host: table.project.host, - number, - issueTypeId: issueType?.id ?? null - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateIssueTypeBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssueTypeBySlug(args) - if (!res.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return res - }, - - patchProjectRowContent: (cacheKey, rowId, patch) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return - } - const previousRow = table.rows.find((r) => r.id === rowId) - if (!previousRow) { - return - } - const nextContent = { ...previousRow.content } - if (patch.title !== undefined) { - nextContent.title = patch.title - } - if (patch.body !== undefined) { - nextContent.body = patch.body - } - if (patch.state !== undefined) { - // Why: ProjectV2 row.state is GitHub's UPPERCASE enum ('OPEN'|'CLOSED'|'MERGED') but the dialog tracks lowercase; upper-case so the patch matches the canonical row shape. - nextContent.state = patch.state.toUpperCase() - } - if (patch.labels !== undefined) { - const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l])) - nextContent.labels = patch.labels.map( - (name) => existingByName.get(name) ?? { name, color: '808080' } - ) - } - if (patch.assignees !== undefined) { - const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u])) - nextContent.assignees = patch.assignees.map( - (login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null } - ) - } - const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent } - applyRowPatch(set, cacheKey, rowId, nextRow) - }, - - getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return null - } - const state = get() - const key = - sourceContext?.provider === 'github' - ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) - : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) - return get().workItemsCache[key]?.data ?? null - }, - - getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { sources: null, error: null } - } - const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) - const entry = get().workItemsCache[key] - return { - sources: entry?.sources ?? null, - error: entry?.error ?? null - } - }, - - getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { - const cache = get().workItemsCache - const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) - const primary = cache[primaryKey]?.sources - if (primary) { - return primary - } - const prefix = primaryKey - for (const [key, entry] of Object.entries(cache)) { - if (key.startsWith(prefix) && entry.sources) { - return entry.sources - } - } - return null - }, - - fetchWorkItems: async ( - repoId, - repoPath, - limit, - query, - options - ): Promise => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return [] - } - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - options?.sourceContext - ) - const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) - const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) - const key = workItemsCacheKey(repoId, limit, query, cacheScope) - const cached = get().workItemsCache[key] - if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { - return cached.data ?? [] - } - - const requestInvalidationNonce = requestState.workItemsInvalidationNonce - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - repoId, - repoPath, - options?.sourceContext - ) - const inflightKey = workItemsInflightRequestKey(key, requestContext.target) - const existing = inflightWorkItemsRequests.get(inflightKey) - if (existing) { - // Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too). - if ( - (options?.force && !existing.force) || - (options?.noCache && !existing.noCache) || - (options?.requireComplete && !existing.requireComplete) - ) { - await existing.promise.catch(() => {}) - } else { - return existing.promise - } - } - - const request = (async () => { - await acquireWorkItemSlot() - try { - const envelope = await listGitHubWorkItemsForRepo(requestContext, { - limit, - query: query || undefined, - ...(options?.noCache ? { noCache: true } : {}) - }) - // Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id. - const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId })) - if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { - throw new Error('GitHub work-item fetch returned a partial result.') - } - // Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2). - const issuesError = envelope.errors?.issues - // Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools. - if (issuesError && !envelope.sources.issues) { - console.warn( - '[workItems] dropping issues-side error with no resolved source:', - issuesError - ) - } - const errorForCache: WorkItemsCacheError | undefined = - issuesError && envelope.sources.issues - ? { ...issuesError, source: envelope.sources.issues } - : undefined - const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) - const currentHostId = getGitHubWorkItemSourceHostId( - get(), - currentRepo, - options?.sourceContext - ) - // Why: repo ownership changed, so this response belongs to an older execution-host bucket (host focus changes alone are fine). - if ((currentHostId ?? null) !== (ownerHostId ?? null)) { - return items - } - // Why: the old promise can still settle after the in-flight clear; don't let pre-flip source data repopulate the cache once the invalidation nonce changed. - if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { - return items - } - // Why: TaskPage useShallow-selects cache entry refs. A new { ...entry, fetchedAt } - // still remaps every visible row. IPC structuredClone rebuilds nested records, so - // data === previous.data never holds — reconcile structurally, then either mutate - // fetchedAt in place or write one entry that keeps unchanged row/meta refs. - set((s) => { - const previousEntry = s.workItemsCache[key] - const previousData = previousEntry?.data ?? [] - const reconciled = reconcileCatalogRows( - previousData, - items, - (row) => `${row.repoId}\0${row.id}` - ) - const nextFellBack = envelope.issueSourceFellBack ? true : undefined - const sourcesUnchanged = structuralValuesEqual(previousEntry?.sources, envelope.sources) - const errorUnchanged = structuralValuesEqual(previousEntry?.error, errorForCache) - const fellBackUnchanged = previousEntry?.issueSourceFellBack === nextFellBack - if ( - previousEntry && - reconciled === previousData && - sourcesUnchanged && - errorUnchanged && - fellBackUnchanged - ) { - previousEntry.fetchedAt = Date.now() - return {} - } - const previousSources = previousEntry?.sources - const previousError = previousEntry?.error - return { - workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, { - // Why: `reconciled` already is `previousEntry.data` when nothing changed — - // reconcileCatalogRows returns the previous array on a structural match. - data: reconciled, - fetchedAt: Date.now(), - sources: - sourcesUnchanged && previousSources !== undefined - ? previousSources - : envelope.sources, - ...(errorForCache - ? { - error: - errorUnchanged && previousError !== undefined ? previousError : errorForCache - } - : {}), - ...(nextFellBack ? { issueSourceFellBack: true } : {}) - }) - } - }) - return items - } catch (err) { - // Why: rethrow but keep the stale cache entry so the UI still renders while the user retries. - if (!isGitHubWorkItemsSshRemoteRequiredError(err)) { - console.error('Failed to fetch GitHub work items:', err) - } - throw err - } finally { - releaseWorkItemSlot() - inflightWorkItemsRequests.delete(inflightKey) - } - })() - - inflightWorkItemsRequests.set(inflightKey, { - promise: request, - force: Boolean(options?.force), - noCache: Boolean(options?.noCache), - requireComplete: Boolean(options?.requireComplete) - }) - return request - }, - - fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { items: [], failedCount: 0, githubUnavailable: false } - } - const state = get() - let failedCount = 0 - let requestFailureCount = 0 - let unavailableFailureCount = 0 - let skippedSourceCount = 0 - const perProjectResults = await Promise.all( - repos.map(async (r) => { - try { - return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { - ...options, - sourceContext: r.sourceContext ?? options?.sourceContext - }) - } catch (err) { - // Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute. - // Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote. - if (isGitHubWorkItemsSshRemoteRequiredError(err)) { - if (options?.requireComplete) { - requestFailureCount += 1 - failedCount += 1 - } - skippedSourceCount += 1 - return [] as GitHubWorkItem[] - } - requestFailureCount += 1 - if (isGitHubUnavailableWorkItemsError(err)) { - unavailableFailureCount += 1 - } - const key = - r.sourceContext?.provider === 'github' - ? workItemsCacheKey( - r.repoId, - perRepoLimit, - query, - getTaskSourceCacheScope(r.sourceContext) - ) - : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) - const cached = get().workItemsCache[key]?.data - if (cached && options?.allowStaleFallback !== false) { - console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) - return cached - } - console.warn(`[workItems] ${r.repoId} failed:`, err) - failedCount += 1 - return [] as GitHubWorkItem[] - } - }) - ) - const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) - // Why: only claim global unavailability when every eligible source failed for a reachability reason; skipped SSH repos aren't GitHub sources here. - const githubUnavailable = - requestFailureCount > 0 && - requestFailureCount === repos.length - skippedSourceCount && - unavailableFailureCount === requestFailureCount - return { - items: merged, - failedCount, - githubUnavailable, - ...(requestFailureCount > 0 ? { requestFailureCount } : {}) - } - }, - - fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { items: [], failedCount: 0, errorTypes: [] } - } - let failedCount = 0 - const errorTypes: ClassifiedError['type'][] = [] - const perProjectResults = await Promise.all( - repos.map(async (r) => { - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - r.repoId, - r.path, - r.sourceContext - ) - await acquireWorkItemSlot() - try { - const envelope = await listGitHubWorkItemsForRepo(requestContext, { - limit: perRepoLimit, - query: query || undefined, - page, - ...(options?.noCache ? { noCache: true } : {}) - }) - // Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6). - if (envelope.errors?.issues) { - const { type, message } = envelope.errors.issues - // Why: only the 1000-result-window 422 may drive the unreachable - // clamp; demote other validation errors so they read as failures. - errorTypes.push( - type === 'validation_error' && - !GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message) - ? 'unknown' - : type - ) - console.warn( - `[workItems] next page ${r.repoId} issues-side partial failure:`, - envelope.errors.issues - ) - } - if (envelope.errors?.prs) { - // Why: the window 422 is issue-side only — a PR-side validation - // error must never join the unreachable signal. - const { type } = envelope.errors.prs - errorTypes.push(type === 'validation_error' ? 'unknown' : type) - console.warn( - `[workItems] next page ${r.repoId} prs-side partial failure:`, - envelope.errors.prs - ) - } - if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { - failedCount += 1 - return [] as GitHubWorkItem[] - } - return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId })) - } catch (err) { - if (isGitHubWorkItemsSshRemoteRequiredError(err)) { - if (options?.requireComplete) { - failedCount += 1 - } - return [] as GitHubWorkItem[] - } - console.warn(`[workItems] next page ${r.repoId} failed:`, err) - failedCount += 1 - return [] as GitHubWorkItem[] - } finally { - releaseWorkItemSlot() - } - }) - ) - const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) - return { items: merged, failedCount, errorTypes } - }, - - countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { totalCount: 0, totalPages: 0 } - } - const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) - // Why: GitHub 422s pages that start past its 1000-result search window. - const maxReachablePages = Math.max(1, Math.ceil(GITHUB_SEARCH_RESULT_WINDOW / normalizedLimit)) - const counts = await Promise.all( - repos.map(async (r) => { - // Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403. - await acquireWorkItemSlot() - try { - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - r.repoId, - r.path, - r.sourceContext - ) - return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) - } catch { - return 0 - } finally { - releaseWorkItemSlot() - } - }) - ) - return { - totalCount: counts.reduce((sum, count) => sum + count, 0), - // Why: repos advance independently by page, so take the max across repos — a sum/page-width undercounts when one repo owns most results. - totalPages: counts.reduce( - (maxPages, count) => - Math.max(maxPages, Math.min(Math.ceil(count / normalizedLimit), maxReachablePages)), - 0 - ) - } - }, - - prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return - } - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const key = - options?.sourceContext?.provider === 'github' - ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) - : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) - const cached = get().workItemsCache[key] - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - options?.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - repoId, - repoPath, - options?.sourceContext - ) - const inflightKey = workItemsInflightRequestKey(key, requestContext.target) - if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(inflightKey)) { - return - } - void get() - .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) - .catch(() => {}) - }, + ...createProjectActions(set, get), + ...createProjectRowActions(set, get), + ...createWorkItemFetchActions(set, get), + ...createWorkItemAggregateActions(get), + ...createPullRequestActions(set, get), + ...createIssueActions(set, get), + ...createCheckActions(set, get), + ...createCommentFetchActions(set, get), + ...createCommentMutationActions(set, get), + ...createConversationCommentActions(set, get), + ...createReviewThreadActions(set, get), + ...createStaleWorktreeRefreshActions(get), + ...createRefreshRoutingActions(set, get), + ...createRefreshEventActions(set, get), + ...createRefreshSweepActions(set, get), + ...createWorkItemMutationActions(set, get), initGitHubCache: async () => { try { @@ -3054,1814 +100,5 @@ export const createGitHubSlice: StateCreator = (s } catch (err) { console.error('Failed to load GitHub cache from disk:', err) } - }, - - fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repoLookup = getGitHubRepoLookupIndex(get().repos) - const repo = options?.repoId - ? repoLookup.findById(options.repoId) - : repoLookup.findByPath(repoPath) - const repoId = options?.repoId ?? repo?.id - const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) - const cacheKey = prCacheKey( - repoPath, - repoId, - branch, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - const cached = get().prCache[cacheKey] - const hostedReviewCacheKey = getHostedReviewCacheKey( - repoPath, - branch, - requestSettings, - repoId, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - // Why: a prior linkedPR-less caller may have cached null for this branch; refetch so the cached miss can now resolve via the linkedPR path. - const linkedPRNumber = options?.linkedPRNumber ?? null - const explicitFallbackPRNumber = options?.fallbackPRNumber ?? null - const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( - get(), - repoPath, - repoId, - branch, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - const fallbackPRNumber = - linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null - const fallbackPRSource: GitHubPRFallbackSource | null = - linkedPRNumber != null || fallbackPRNumber == null - ? null - : (options?.fallbackPRSource ?? - (explicitFallbackPRNumber != null ? 'explicit' : 'hosted-review')) - const lookupHintKey = prLookupHintKey(linkedPRNumber, fallbackPRNumber) - const linkedRefetch = - cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null) - if (!options?.force && !linkedRefetch && isFresh(cached)) { - // Why: even a fresh cache hit carries the head-scoped divergence signal; if a prior clear was declined for a mid-request head move and we're back on that head, clear the durable link. - if ( - options?.worktreeId && - linkedPRNumber != null && - cached?.data?.headDivergedFromMergedPRAtOid != null - ) { - const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null - if ( - shouldClearDivergedLinkedMergedPR({ - pr: cached.data, - linkedPRNumber, - requestHeadOid: currentHeadOid - }) - ) { - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - shouldApply: (worktree) => - shouldApplyDivergedLinkedPRClear({ - worktree, - linkedPRNumber, - branch, - requestHeadOid: currentHeadOid - }) - } - ) - } - } - return cached.data - } - - const inflightRequest = inflightPRRequests.get(cacheKey) - if ( - inflightRequest && - (!options?.force || inflightRequest.force) && - inflightRequest.lookupHintKey === lookupHintKey && - !linkedRefetch - ) { - return inflightRequest.promise - } - - const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1 - const requestStartedAt = Date.now() - const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey] - const requestStartedPRRefreshState = get().prRefreshStates[cacheKey] - const requestStartedPRRefreshToken = buildGitHubPRRefreshStateClearToken( - requestStartedPRRefreshState, - get().prRefreshSequences, - cacheKey - ) - prRequestGenerations.set(cacheKey, generation) - - const request = (async () => { - try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const candidateWorktree = options?.worktreeId - ? findWorktreeById(get(), options.worktreeId) - : null - const requestHeadOid = candidateWorktree?.head ?? null - const outcome = runtimeRepo - ? await callRuntimeRpc( - runtimeRepo.target, - 'github.prForBranch', - { - repo: runtimeRepo.repo.id, - branch, - linkedPRNumber, - currentHeadOid: requestHeadOid, - ...(fallbackPRNumber !== null - ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } - : {}) - }, - { timeoutMs: 30_000 } - ).then((result) => normalizeGitHubPRForBranchOutcome(result)) - : await (async () => { - const candidate: GitHubPRRefreshCandidate = { - repoId: repoId ?? '', - repoPath, - repoKind: repo?.kind ?? 'git', - branch, - cacheKey, - worktreeId: options?.worktreeId, - currentHeadOid: requestHeadOid, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource, - connectionId: repo?.connectionId ?? null, - executionHostId: repo?.executionHostId ?? null, - cachedFetchedAt: cached?.fetchedAt ?? null, - cachedHasPR: cached?.data ? true : cached ? false : null, - cachedPRState: cached?.data?.state ?? null, - cachedChecksStatus: cached?.data?.checksStatus ?? null, - cachedMergeable: cached?.data?.mergeable ?? null, - cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null - } - const response = window.api.gh.refreshPRNow - ? await window.api.gh.refreshPRNow({ candidate }) - : await window.api.gh.prForBranch({ - repoPath, - repoId, - branch, - linkedPRNumber, - fallbackPRNumber, - acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null, - currentHeadOid: requestHeadOid - }) - return normalizeGitHubPRForBranchOutcome(response) - })() - const pr: PRInfo | null = - outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null - if (outcome.kind === 'upstream-error') { - // Why: the runtime RPC path skips the coordinator broadcast that fills prRefreshStates on native, so record the classified error here for Checks parity with native (design criterion 2). - if (runtimeRepo && prRequestGenerations.get(cacheKey) === generation) { - set((s) => { - const nextStates = { ...s.prRefreshStates } - delete nextStates[cacheKey] - nextStates[cacheKey] = { - status: 'error', - reason: 'swr', - updatedAt: Date.now(), - message: outcome.message, - errorType: outcome.errorType, - nextAutoRetryAt: outcome.nextAutoRetryAt, - retryDisabledUntil: outcome.retryDisabledUntil - } - return { prRefreshStates: nextStates } - }) - } - return cached?.data ?? null - } - if (prRequestGenerations.get(cacheKey) === generation) { - let skippedStaleLinkedPRLookup = false - let didUpdatePRCache = false - set((s) => { - // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. - if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { - skippedStaleLinkedPRLookup = true - return {} - } - const updates = setGitHubPRResultCaches(s, { - prCacheKey: cacheKey, - repoPath, - branch, - settings: requestSettings, - repoId, - connectionId: repo?.connectionId, - executionHostId: repo?.executionHostId, - hasRepoOwner: repo !== undefined, - pr, - fetchedAt: outcome.fetchedAt, - worktreeId: options?.worktreeId, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource, - requestStartedAt, - requestStartedEntry: requestStartedHostedReviewEntry - }) - didUpdatePRCache = updates.prCache !== undefined - return updates - }) - if (skippedStaleLinkedPRLookup) { - return null - } - if (didUpdatePRCache) { - debouncedSaveCache(get()) - } - const linkedPRWorktree = - options?.worktreeId && linkedPRNumber != null - ? findUniqueWorktreeById( - get(), - options.worktreeId, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) - : null - if ( - options?.worktreeId && - linkedPRWorktree && - linkedPRNumber != null && - shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) - ) { - // Why: only clear the durable link that produced this exact probe; drift means the stale result no longer owns the worktree. - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById( - get(), - options.worktreeId!, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) ?? undefined, - linkedPRNumber, - branch, - requestHeadOid - }) - } - ) - } - if ( - options?.worktreeId && - linkedPRWorktree && - linkedPRNumber != null && - shouldClearBranchMismatchedLinkedOpenPR({ - pr, - linkedPRNumber, - branch, - requestHeadOid, - pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null - }) - ) { - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - // Why: the branch-scoped PR refetch below updates both caches; the generic metadata refresh would duplicate provider work. - suppressHostedReviewRefresh: true, - shouldApply: () => - shouldApplyBranchMismatchedLinkedPRClear({ - worktree: - findUniqueWorktreeById( - get(), - options.worktreeId!, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) ?? undefined, - linkedPRNumber, - branch, - requestHeadOid - }) - } - ) - // Re-resolve by branch now so Checks recover this refresh instead of serving the stale linked PR. - void get().fetchPRForBranch(repoPath, branch, { - force: true, - repoId, - worktreeId: options.worktreeId - }) - } - } - if ( - shouldPreserveExistingPRForFallbackMiss({ - currentPR: get().prCache[cacheKey]?.data, - nextPR: pr, - state: get(), - worktreeId: options?.worktreeId, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource - }) - ) { - return get().prCache[cacheKey]?.data ?? null - } - return pr ?? null - } catch (err) { - console.error('Failed to fetch PR:', err) - return null - } finally { - const activeRequest = inflightPRRequests.get(cacheKey) - if (activeRequest?.generation === generation) { - inflightPRRequests.delete(cacheKey) - if (prRequestGenerations.get(cacheKey) === generation) { - prRequestGenerations.delete(cacheKey) - } - } - if (requestStartedPRRefreshToken) { - get().expireGitHubPRRefreshState(cacheKey, requestStartedPRRefreshToken) - } - } - })() - - inflightPRRequests.set(cacheKey, { - promise: request, - force: Boolean(options?.force), - generation, - lookupHintKey - }) - return request - }, - - fetchIssue: async (repoPath, number, options) => { - const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - String(number), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const cached = get().issueCache[cacheKey] - if (isFresh(cached)) { - return cached.data - } - - const inflightRequest = inflightIssueRequests.get(cacheKey) - if (inflightRequest) { - return inflightRequest - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const issue = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.issue', - { repo: requestContext.target.runtimeRepoId, number }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.issue({ - repoPath, - repoId, - number, - sourceContext: options?.sourceContext - }) - set((s) => ({ - issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { - data: issue, - fetchedAt: Date.now() - }) - })) - debouncedSaveCache(get()) - return issue - } catch (err) { - console.error('Failed to fetch issue:', err) - set((s) => ({ - issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { - data: null, - fetchedAt: Date.now() - }) - })) - debouncedSaveCache(get()) - return null - } finally { - inflightIssueRequests.delete(cacheKey) - } - })() - - inflightIssueRequests.set(cacheKey, request) - return request - }, - - fetchPRChecks: async ( - repoPath, - prNumber, - branch, - headSha, - prRepo, - options - ): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prChecksCacheSuffix(prNumber, prRepo, headSha), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const legacyCacheKey = headSha - ? sourceScopedRepoCacheKey( - repoPath, - repoId, - prChecksCacheSuffix(prNumber, prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - : cacheKey - const inflightKey = cacheKey - const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] - if ( - !options?.force && - !options?.noCache && - isFresh(cached, getPRChecksCacheTtl(cached)) && - (!headSha || cached.headSha === headSha) - ) { - const cachedChecks = cached.data ?? [] - const prStatusUpdate = syncPRChecksStatus( - get(), - repoPath, - repoId, - branch, - cachedChecks, - cached.headSha, - prRepo, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - if (prStatusUpdate) { - set(prStatusUpdate) - debouncedSaveCache(get()) - } - return cachedChecks - } - - const inflightRequest = inflightChecksRequests.get(inflightKey) - if (inflightRequest) { - if ( - (options?.force && !inflightRequest.force) || - (options?.noCache && !inflightRequest.noCache) - ) { - await inflightRequest.promise.catch(() => {}) - } else { - return inflightRequest.promise - } - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const checks = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.prChecks', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: Boolean(options?.force || options?.noCache) - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prChecks({ - repoPath, - repoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: Boolean(options?.force || options?.noCache), - sourceContext: options?.sourceContext - })) as PRCheckDetail[]) - set((s) => { - const nextState: Partial = { - checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { - data: checks, - fetchedAt: Date.now(), - headSha - }) - } - - const prStatusUpdate = syncPRChecksStatus( - s, - repoPath, - repoId, - branch, - checks, - headSha, - prRepo, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - if (prStatusUpdate?.prCache) { - nextState.prCache = prStatusUpdate.prCache - } - - return nextState - }) - debouncedSaveCache(get()) - return checks - } catch (err) { - console.error('Failed to fetch PR checks:', err) - const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] - if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) { - return latestCached.data - } - return [] - } finally { - inflightChecksRequests.delete(inflightKey) - } - })() - - inflightChecksRequests.set(inflightKey, { - promise: request, - force: Boolean(options?.force), - noCache: Boolean(options?.force || options?.noCache) - }) - return request - }, - - fetchPRCheckDetails: async (repoPath, args, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const requestTarget = requestContext.target - return requestTarget.kind === 'environment' - ? await withGitHubCheckDetailsTimeout((signal) => - callRuntimeRpc( - { kind: 'environment', environmentId: requestTarget.environmentId }, - 'github.prCheckDetails', - { - repo: requestTarget.runtimeRepoId, - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - { timeoutMs: 30_000, signal } - ) - ) - : await withGitHubCheckDetailsTimeout(() => - window.api.gh.prCheckDetails({ - repoPath, - repoId, - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null, - sourceContext: options?.sourceContext - }) - ) - }, - - fetchPRComments: async (repoPath, prNumber, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const cached = get().commentsCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? [] - } - - const inflightRequest = inflightCommentsRequests.get(cacheKey) - if (inflightRequest) { - return inflightRequest - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const comments = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.prComments', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prComments({ - repoPath, - repoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force, - sourceContext: options?.sourceContext - })) as PRComment[]) - set((s) => ({ - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: comments, - fetchedAt: Date.now() - }) - })) - return comments - } catch (err) { - console.error('Failed to fetch PR comments:', err) - return get().commentsCache[cacheKey]?.data ?? [] - } finally { - inflightCommentsRequests.delete(cacheKey) - } - })() - - inflightCommentsRequests.set(cacheKey, request) - return request - }, - - addPRConversationComment: async (repoPath, prNumber, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult - try { - result = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.addIssueComment', - { - repo: requestContext.target.runtimeRepoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addIssueComment({ - repoPath, - repoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - const error = err instanceof Error ? err.message : 'Failed to post comment.' - return { ok: false, error } - } - if (!hasUsableCommentPayload(result)) { - return result.ok - ? { - ok: false, - error: translate( - 'auto.store.slices.github.f129c42773', - 'GitHub did not return the new comment.' - ) - } - : result - } - set((s) => { - const entry = s.commentsCache[cacheKey] - return { - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: mergePRCommentIntoList(entry?.data, result.comment), - fetchedAt: Date.now() - }) - } - }) - return result - }, - - addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult - try { - result = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.addPRReviewCommentReply', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addPRReviewCommentReply({ - repoPath, - repoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - const error = err instanceof Error ? err.message : 'Failed to post reply.' - return { ok: false, error } - } - if (!hasUsableCommentPayload(result)) { - return result.ok - ? { - ok: false, - error: translate( - 'auto.store.slices.github.f129c42773', - 'GitHub did not return the new comment.' - ) - } - : result - } - const comment: PRComment = { - ...result.comment, - threadId: result.comment.threadId ?? options?.threadId, - path: result.comment.path ?? options?.path, - line: result.comment.line ?? options?.line - } - set((s) => { - const entry = s.commentsCache[cacheKey] - return { - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: mergePRCommentIntoList(entry?.data, comment), - fetchedAt: Date.now() - }) - } - }) - return { ok: true, comment } - }, - - setPRCommentReaction: async ( - repoPath, - prNumber, - reactionSubjectId, - content, - reacted, - options - ) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const previousComment = get().commentsCache[cacheKey]?.data?.find( - (comment) => comment.reactionSubjectId === reactionSubjectId - ) - const previousReaction = previousComment?.reactions?.find( - (reaction) => reaction.content === content - ) - set((state) => { - const entry = state.commentsCache[cacheKey] - if (!entry?.data) { - return state - } - return { - commentsCache: { - ...state.commentsCache, - [cacheKey]: { - ...entry, - data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) - } - } - } - }) - - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false - try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.setPRCommentReaction', - { - repo: requestContext.target.runtimeRepoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRCommentReaction({ - repoPath, - repoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - console.error('Failed to update PR comment reaction:', err) - } - if (!ok && previousComment) { - set((state) => { - const entry = state.commentsCache[cacheKey] - if (!entry?.data) { - return state - } - return { - commentsCache: { - ...state.commentsCache, - [cacheKey]: { - ...entry, - data: restoreReactionOnSubject( - entry.data, - reactionSubjectId, - content, - previousReaction - ) - } - } - } - }) - } - return ok - }, - - resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - - // Optimistic toggle of isResolved for this thread; reverts if the API call fails. - const prev = get().commentsCache[cacheKey]?.data - if (prev) { - set((s) => ({ - commentsCache: { - ...s.commentsCache, - [cacheKey]: { - ...s.commentsCache[cacheKey], - data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c)) - } - } - })) - } - - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false - try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.resolveReviewThread', - { - repo: requestContext.target.runtimeRepoId, - threadId, - resolve, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.resolveReviewThread({ - repoPath, - repoId, - threadId, - resolve, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - console.error('Failed to update review thread:', err) - ok = false - } - if (!ok && prev) { - // Revert optimistic update on failure - set((s) => ({ - commentsCache: { - ...s.commentsCache, - [cacheKey]: { ...s.commentsCache[cacheKey], data: prev } - } - })) - } - return ok - }, - - enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { - const state = get() - const worktree = findWorktreeById(state, worktreeId) - const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null - if (!candidate) { - return - } - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: bypassesGitHubPRRefreshFreshness(reason), - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - return - } - if (!shouldEnqueueLocalPRRefresh(candidate)) { - return - } - enqueueLocalGitHubPRRefresh({ candidate, reason, priority }, async () => { - await get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: bypassesGitHubPRRefreshFreshness(reason), - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - }) - }, - - reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { - const state = get() - const candidates = worktreeIds - .map((id) => { - const worktree = findWorktreeById(state, id) - return worktree ? buildPRRefreshCandidate(state, worktree) : null - }) - .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) - const localCandidates: GitHubPRRefreshCandidate[] = [] - for (const candidate of candidates) { - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - continue - } - if (shouldEnqueueLocalPRRefresh(candidate)) { - localCandidates.push(candidate) - } - } - const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates - if (reportVisible) { - void reportVisible({ candidates: localCandidates, generation }).catch((err) => { - console.warn('Failed to report visible PR refresh candidates:', err) - }) - } - }, - - bumpGitHubPRVisibleRefreshGeneration: () => { - set((s) => ({ prVisibleRefreshGeneration: s.prVisibleRefreshGeneration + 1 })) - }, - - applyGitHubPRRefreshEvent: (event) => { - // Why: local-repo sidebar refresh routes through the main PR coordinator, so run the same guarded diverged-merged-PR clear. - const divergedLinkedPRClears: { - worktreeId: string - linkedPRNumber: number - branch: string - requestHeadOid: string | null - executionHostId: string - }[] = [] - const branchMismatchedLinkedPRClears: { - worktreeId: string - linkedPRNumber: number - branch: string - requestHeadOid: string | null - executionHostId: string - }[] = [] - let didUpdatePRCache = false - set((s) => { - let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined - const nextSequences = { ...s.prRefreshSequences } - const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates) - const nextStates = { ...prunedStates } - let nextPRCache = s.prCache - let nextHostedReviewCache = s.hostedReviewCache ?? {} - let changed = prunedStates !== s.prRefreshStates - - for (const alias of event.aliases) { - const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) - const previousSequence = nextSequences[alias.cacheKey] ?? 0 - if ( - event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence - ) { - if (event.outcome || event.status !== 'in-flight') { - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - } - continue - } - // Why: delete-then-set re-orders this key last so capPrRefreshSequences evicts idle, not active, keys. - delete nextSequences[alias.cacheKey] - nextSequences[alias.cacheKey] = event.sequence - changed = true - - if (event.outcome) { - const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey) - const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey) - prRefreshStartedHostedReviewEntries.delete(startedEntryKey) - if (previousSequence !== event.sequence) { - deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) - } - delete nextStates[alias.cacheKey] - if (event.outcome.kind === 'upstream-error') { - nextStates[alias.cacheKey] = { - status: 'error', - reason: event.reason, - updatedAt: Date.now(), - message: event.outcome.message, - errorType: event.outcome.errorType, - nextAutoRetryAt: event.outcome.nextAutoRetryAt, - retryDisabledUntil: event.outcome.retryDisabledUntil - } - continue - } - const data = - event.outcome.kind === 'found' - ? (() => { - const pr = event.outcome.pr - const checksCacheKeys = [ - ...(alias.repoId - ? [ - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - alias.repoId, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - runtimeScopedRepoCacheKey( - alias.repoPath, - alias.repoId, - prChecksCacheSuffix(pr.number, pr.prRepo), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - undefined, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - runtimeScopedRepoCacheKey( - alias.repoPath, - undefined, - prChecksCacheSuffix(pr.number, pr.prRepo), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ), - `${alias.repoPath}::pr-checks::${pr.number}` - ] - const checksEntry = checksCacheKeys - .map((key) => s.checksCache[key]) - .find((entry) => entry?.data) - if ( - checksEntry?.data && - checksEntry.headSha && - pr.headSha && - checksEntry.headSha === pr.headSha && - event.outcome.fetchedAt - checksEntry.fetchedAt < - getPRChecksCacheTtl(checksEntry) - ) { - return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } - } - return pr - })() - : null - const linkedPRNumber = alias.linkedPRNumber ?? null - // Why: one outcome fans out to many aliases; build one lazy index instead of rescanning worktrees per alias. - const worktreeLookupIndex = - alias.worktreeId && linkedPRNumber != null - ? (linkedWorktreeLookupIndex ??= buildWorktreeLookupIndex(s)) - : undefined - // Why: a queued refresh finishing after the user unlinks an exact PR must not restore the manual-link UI. - if ( - isStaleExactLinkedPRLookup(s, alias.worktreeId, linkedPRNumber, worktreeLookupIndex) - ) { - continue - } - if (event.outcome.kind === 'found' && alias.worktreeId) { - const requestHeadOid = alias.currentHeadOid ?? null - const worktree = - linkedPRNumber != null - ? findUniqueWorktreeById( - s, - alias.worktreeId, - aliasExecutionHostId, - worktreeLookupIndex - ) - : null - // Why: only the sequence-gate winner owns metadata side effects; late outcomes must not unlink a newer PR. - if ( - worktree && - linkedPRNumber != null && - shouldClearDivergedLinkedMergedPR({ - pr: event.outcome.pr, - linkedPRNumber, - requestHeadOid - }) - ) { - divergedLinkedPRClears.push({ - worktreeId: alias.worktreeId, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - executionHostId: aliasExecutionHostId - }) - } else if ( - worktree && - linkedPRNumber != null && - shouldClearBranchMismatchedLinkedOpenPR({ - pr: event.outcome.pr, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - pushTargetBranch: worktree.pushTarget?.branchName ?? null - }) - ) { - branchMismatchedLinkedPRClears.push({ - worktreeId: alias.worktreeId, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - executionHostId: aliasExecutionHostId - }) - } - } - const nextCaches = applyGitHubPRResultToCaches({ - prCache: nextPRCache, - hostedReviewCache: nextHostedReviewCache, - prCacheKey: alias.cacheKey, - repoPath: alias.repoPath, - branch: alias.branch, - settings: s.settings, - repoId: alias.repoId, - connectionId: alias.connectionId, - executionHostId: aliasExecutionHostId, - hasRepoOwner: true, - pr: data, - fetchedAt: event.outcome.fetchedAt, - state: s, - worktreeId: alias.worktreeId, - linkedPRNumber: alias.linkedPRNumber, - fallbackPRNumber: alias.fallbackPRNumber, - fallbackPRSource: alias.fallbackPRSource, - requestStartedAt: event.requestStartedAt, - requestStartedEntry - }) - didUpdatePRCache = didUpdatePRCache || nextCaches.prCache !== nextPRCache - nextPRCache = nextCaches.prCache - nextHostedReviewCache = nextCaches.hostedReviewCache - continue - } - - if (event.status) { - if (previousSequence !== event.sequence) { - deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) - } - if (event.status === 'in-flight' && event.requestStartedAt !== undefined) { - const hostedReviewCacheKey = getHostedReviewCacheKey( - alias.repoPath, - alias.branch, - s.settings, - alias.repoId, - alias.connectionId, - aliasExecutionHostId, - true - ) - setPRRefreshStartedHostedReviewEntry( - prRefreshStartedEntryKey(event.sequence, alias.cacheKey), - s.hostedReviewCache[hostedReviewCacheKey] - ) - } else { - // Why: pause/skip can follow an in-flight broadcast with no outcome; drop the stale request-start snapshot. - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - } - // Why: delete-then-set re-orders this key last so capRecordByInsertionOrder evicts idle, not active, keys. - delete nextStates[alias.cacheKey] - const isPaused = event.status === 'paused' - nextStates[alias.cacheKey] = { - status: event.status, - reason: event.reason, - updatedAt: Date.now(), - pausedUntil: event.pausedUntil, - skippedReason: event.skippedReason, - // Why: paused = rate-limit gate; map pausedUntil into the schedule to show auto-retry and disable manual Retry. - nextAutoRetryAt: isPaused ? event.pausedUntil : undefined, - retryDisabledUntil: isPaused ? event.pausedUntil : undefined - } - } - } - - return changed - ? { - prRefreshSequences: capPrRefreshSequences(nextSequences), - // Why: bound prRefreshStates with status-aware eviction so visible in-progress pills survive. - prRefreshStates: capPrRefreshStates(nextStates), - prCache: nextPRCache, - hostedReviewCache: nextHostedReviewCache - } - : {} - }) - if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { - debouncedSaveCache(get()) - } - for (const clear of divergedLinkedPRClears) { - void get().updateWorktreeMeta( - clear.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - } - ) - } - for (const clear of branchMismatchedLinkedPRClears) { - void get().updateWorktreeMeta( - clear.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyBranchMismatchedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - } - ) - } - }, - - refreshAllGitHub: () => { - // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. - set((s) => { - const next = { - commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, - prCache: evictStaleEntries(s.prCache), - issueCache: evictStaleEntries(s.issueCache), - checksCache: evictStaleEntries(s.checksCache), - workItemsCache: evictStaleEntries(s.workItemsCache), - projectViewCache: evictStaleEntries(s.projectViewCache), - prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) - } - // Why: each eviction helper returns its input untouched when nothing changed, so an - // unchanged sweep can return `s` and avoid waking every subscriber on window resume. - return next.commentsCache === s.commentsCache && - next.prCache === s.prCache && - next.issueCache === s.issueCache && - next.checksCache === s.checksCache && - next.workItemsCache === s.workItemsCache && - next.projectViewCache === s.projectViewCache && - next.prRefreshStates === s.prRefreshStates - ? s - : next - }) - - // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. - - // Only re-fetch PR/issue entries that are already stale — skip fresh ones - const state = get() - const cardProps = state.worktreeCardProperties ?? [] - const rawCardProps = cardProps as readonly string[] - const shouldRefreshIssues = shouldRefreshIssueDecorations(state) - const isPRStatusGrouping = state.groupBy === 'pr-status' - const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state) - const shouldRefreshPRs = - isPRStatusGrouping || - rightSidebarShowsPR || - (state.settings?.experimentalNewWorktreeCardStyle === true - ? cardProps.includes('status') - : cardProps.includes('pr') || rawCardProps.includes('ci')) - if (!shouldRefreshPRs && !shouldRefreshIssues) { - return - } - - const now = Date.now() - const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] - const repoLookup = getGitHubRepoLookupIndex(state.repos) - - for (const worktrees of Object.values(state.worktreesByRepo)) { - for (const wt of worktrees) { - const repo = repoLookup.findById(wt.repoId) - if (!repo) { - continue - } - - const branch = wt.branch.replace(/^refs\/heads\//, '') - if (shouldRefreshPRs && !wt.isBare && branch) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const prKey = prCacheKey( - repo.path, - repo.id, - branch, - ownerSettings, - repo.connectionId, - repo.executionHostId - ) - const prEntry = state.prCache[prKey] - if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) - if (candidate) { - stalePRCandidates.push({ - candidate, - score: - (state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) + - wt.lastActivityAt - }) - } - } - } - if (shouldRefreshIssues && wt.linkedIssue) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const issueKey = issueCacheKey( - repo.path, - repo.id, - wt.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - const issueEntry = state.issueCache[issueKey] - if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { - void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) - } - } - } - } - const candidatesToRefresh = stalePRCandidates - .sort((a, b) => b.score - a.score) - .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) - for (const { candidate } of candidatesToRefresh) { - const candidateSettings = settingsForGitHubRepoOwner( - state.settings, - candidate as Pick - ) - if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) - } - } - }, - - refreshGitHubForWorktree: (worktreeId) => { - const state = get() - let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { - worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - break - } - } - if (!worktree) { - return - } - - const repo = state.repos.find((r) => r.id === worktree.repoId) - if (!repo) { - return - } - - // Invalidate this worktree's cache entries - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const prKey = prCacheKey( - repo.path, - repo.id, - branch, - ownerSettings, - repo.connectionId, - repo.executionHostId - ) - const issueKey = worktree.linkedIssue - ? issueCacheKey( - repo.path, - repo.id, - worktree.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - : '' - - set((s) => { - const updates: Partial = {} - if (s.prCache[prKey]) { - updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } } - } - if (issueKey && s.issueCache[issueKey]) { - updates.issueCache = { - ...s.issueCache, - [issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 } - } - } - return updates - }) - - // Re-fetch (skip when branch is empty — detached HEAD during rebase) - if (!worktree.isBare && branch) { - const candidate = buildPRRefreshCandidate(get(), worktree) - if (candidate) { - if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: true, - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) - } - } - } - if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) - } - }, - - patchWorkItem: (itemId, patch, repoId, options) => { - set((s) => { - const nextCache = { ...s.workItemsCache } - let changed = false - const sourceScope = - options?.sourceContext?.provider === 'github' - ? getTaskSourceCacheScope(options.sourceContext) - : null - for (const key of Object.keys(nextCache)) { - // Why: don't patch another host/account's visually identical issue/PR cache entry. - if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { - continue - } - const entry = nextCache[key] - if (!entry?.data) { - continue - } - // Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`. - const idx = entry.data.findIndex( - (item) => item.id === itemId && (!repoId || item.repoId === repoId) - ) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextCache[key] = { ...entry, data: updatedItems } - changed = true - } - return changed ? { workItemsCache: nextCache } : {} - }) - }, - - setIssueSourcePreference: async (repoId, repoPath, preference) => { - // Why: optimistically patch the local Repo so the segmented control updates this frame; resync via fetchRepos on IPC failure. - set((s) => ({ - repos: s.repos.map((r) => - r.id === repoId - ? { - ...r, - issueSourcePreference: preference === 'auto' ? undefined : preference - } - : r - ) - })) - try { - // Why: use the generic `repos:update` channel so a single write → single `repos:changed` broadcast re-fetches other windows. - // Why: map 'auto' to undefined so persistence drops the key entirely (see main/persistence.ts#updateRepo). - const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } - // Why: route to the repo's owner host (like updateRepo) so the write lands where the repo lives, not the focused runtime. - const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) - await (target.kind === 'local' - ? window.api.repos.update({ repoId, updates }) - : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) - } catch (err) { - console.error('Failed to persist issue-source preference:', err) - // Why: without this toast the pill silently snaps back (optimistic patch + resync) and the user wouldn't know the write failed. - toast.error( - translate('auto.store.slices.github.d49ef4b944', 'Failed to save issue-source preference'), - { - duration: ERROR_TOAST_DURATION - } - ) - // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. - void get().fetchRepos() - } - // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. - clearInflightWorkItemsForRepo(repoId, repoPath) - // Why: evict AFTER the await so an overlapping fetch can't repopulate with pre-flip data; also drops legacy path-scoped keys. - set((s) => { - const prefix = `${repoId}::` - const legacyPrefix = `${repoPath}::` - const next: Record> = {} - for (const [key, entry] of Object.entries(s.workItemsCache)) { - if (!key.startsWith(prefix) && !key.startsWith(legacyPrefix)) { - next[key] = entry - } - } - // Why: the Tasks fetch effect keys on the nonce, not the cache, so bump it to re-run and re-populate the evicted entries. - return { workItemsCache: next, workItemsInvalidationNonce: s.workItemsInvalidationNonce + 1 } - }) - }, - - evictGitHubRepoCaches: (repoId, repoPath) => { - clearInflightWorkItemsForRepo(repoId, repoPath) - set((s) => { - const prefixes = repoCacheKeyPrefixes(repoId, repoPath) - const workItems = evictRepoCacheEntries(s.workItemsCache, prefixes) - const prs = evictRepoCacheEntries(s.prCache, prefixes) - const issues = evictRepoCacheEntries(s.issueCache, prefixes) - const checks = evictRepoCacheEntries(s.checksCache, prefixes) - const comments = evictRepoCacheEntries(s.commentsCache, prefixes) - const updates: Partial = {} - - if (workItems.evicted) { - updates.workItemsCache = workItems.cache - updates.workItemsInvalidationNonce = s.workItemsInvalidationNonce + 1 - } - if (prs.evicted) { - updates.prCache = prs.cache - } - if (issues.evicted) { - updates.issueCache = issues.cache - } - if (checks.evicted) { - updates.checksCache = checks.cache - } - if (comments.evicted) { - updates.commentsCache = comments.cache - } - - return updates - }) - }, - - // Why: activation is the strongest freshness signal; route through the coordinator to keep coalescing/rate-limit guards. - refreshGitHubForWorktreeIfStale: (worktreeId) => { - const state = get() - let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { - worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - break - } - } - if (!worktree) { - return - } - - const repo = state.repos.find((r) => r.id === worktree.repoId) - if (!repo) { - return - } - - const now = Date.now() - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const cardProps = state.worktreeCardProperties ?? [] - const rawCardProps = cardProps as readonly string[] - const shouldRefreshPR = - state.groupBy === 'pr-status' || - (state.settings?.experimentalNewWorktreeCardStyle === true - ? cardProps.includes('status') - : cardProps.includes('pr') || rawCardProps.includes('ci')) || - rightSidebarShowsPullRequestData(state) - - if (shouldRefreshPR && !worktree.isBare && branch) { - const candidate = buildPRRefreshCandidate(state, worktree) - if (candidate) { - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: true, - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) - } - } - } - - if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const issueKey = issueCacheKey( - repo.path, - repo.id, - worktree.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - const issueEntry = state.issueCache[issueKey] - if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { - void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) - } - } } }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 418e4a7ad25..4b0e5237987 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -11,7 +11,7 @@ import type { JiraSiteSelection, JiraViewer } from '../../../../shared/jira-types' -import type { CacheEntry } from './github' +import type { CacheEntry } from '../github/cache-model' import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { jiraConnect, diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index eddbbcf3086..588c6845f3a 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -19,7 +19,7 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../../../shared/linear/workspace-types' -import type { CacheEntry } from './github' +import type { CacheEntry } from '../github/cache-model' import { clampLinearIssueListLimit } from '../../../../shared/linear/issue-read-limits' import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 83a3564cf5b..d5a246b5da5 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { toast } from 'sonner'