refactor(renderer): split GitHub store slice

This commit is contained in:
Neil
2026-08-23 17:29:21 -07:00
parent 0a613d5fed
commit 67857705a3
70 changed files with 5938 additions and 4845 deletions
-1
View File
@@ -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
@@ -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,
@@ -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'
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -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,
@@ -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'
@@ -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'
@@ -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,
@@ -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'
@@ -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,
@@ -1,4 +1,4 @@
import type { CacheEntry } from '@/store/slices/github'
import type { CacheEntry } from '@/store/github/cache-model'
import {
getTaskSourceCacheScope,
type TaskSourceContext
@@ -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'
@@ -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 {
@@ -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<NonNullable<AppState['settings']>, '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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<T>(
cache: Record<string, CacheEntry<T>>,
prefixes: readonly string[]
): { cache: Record<string, CacheEntry<T>>; evicted: boolean } {
let next: Record<string, CacheEntry<T>> | 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<PRCheckDetail[]> | undefined): number {
return entry?.data?.length === 0 ? 10_000 : 60_000
}
@@ -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<T> = {
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<GitHubPRRefreshAlias['fallbackPRSource']>
export type ProjectViewCacheEntry<T> = {
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
}
@@ -0,0 +1,17 @@
import type { AppState } from '../types'
let saveTimer: ReturnType<typeof setTimeout> | 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)
}
@@ -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<T>(
entry: CacheEntry<T> | undefined,
ttl = CACHE_TTL
): entry is CacheEntry<T> {
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<T extends { fetchedAt: number }>(
cache: Record<string, T>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, T> {
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<T extends { fetchedAt: number }>(
cache: Record<string, T>,
key: string,
entry: T
): Record<string, T> {
return evictStaleEntries({ ...cache, [key]: entry })
}
export function capRecordByInsertionOrder<T>(
record: Record<string, T>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, T> {
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
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'fetchPRChecks' | 'fetchPRCheckDetails'> => ({
fetchPRChecks: async (
repoPath,
prNumber,
branch,
headSha,
prRepo,
options
): Promise<PRCheckDetail[]> => {
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<PRCheckDetail[]>(
{ 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<AppState> = {
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<PRCheckRunDetails | null> => {
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<PRCheckRunDetails | null>(
{ 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
})
)
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'fetchPRComments'> => ({
fetchPRComments: async (repoPath, prNumber, options): Promise<PRComment[]> => {
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<PRComment[]>(
{ 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
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'addPRReviewCommentReply' | 'setPRCommentReaction'> => ({
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<GitHubCommentResult>(
{ 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<boolean>(
{ 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
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'addPRConversationComment'> => ({
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<GitHubCommentResult>(
{ 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
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'fetchIssue'> => ({
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<IssueInfo | null>(
{ 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
}
})
@@ -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
}
@@ -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<number, PRComment>()
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'
)
}
@@ -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<PRRefreshState['status']>(['error', 'skipped'])
export const ACTIVE_PR_REFRESH_STATUSES = new Set<PRRefreshState['status']>([
'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<string, number>,
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<string, PRRefreshState>,
cacheKey: string,
now = Date.now()
): PRRefreshState | undefined {
const state = states[cacheKey]
return !state || isExpiredActivePRRefreshState(state, now) ? undefined : state
}
export function pruneExpiredPRRefreshStates(
states: Record<string, PRRefreshState>,
now = Date.now()
): Record<string, PRRefreshState> {
let next: Record<string, PRRefreshState> | 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<string, PRRefreshState>,
maxEntries = MAX_PR_REFRESH_STATE_ENTRIES
): Record<string, PRRefreshState> {
const keys = Object.keys(states)
let toEvict = keys.length - maxEntries
if (toEvict <= 0) {
return states
}
const evicted = new Set<string>()
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<string, PRRefreshState> = {}
for (const key of keys) {
if (!evicted.has(key)) {
capped[key] = states[key]
}
}
return capped
}
export function capPrRefreshSequences(
sequences: Record<string, number>,
maxEntries?: number
): Record<string, number> {
return capRecordByInsertionOrder(sequences, maxEntries)
}
@@ -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<AppState> {
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
}
@@ -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'
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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<GetProjectViewTableResult> => {
await acquireWorkItemSlot()
try {
const envelope =
target.kind === 'environment'
? await callRuntimeRpc<GetProjectViewTableResult>(
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<GitHubProjectMutationResult>(
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<GitHubProjectMutationResult>(
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
}
})
@@ -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<AppState>) => 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<AppState>) => 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 }
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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<GitHubProjectMutationResult>(
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<GitHubProjectMutationResult>(
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<GitHubProjectMutationResult>(
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)
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'fetchPRForBranch'> => ({
fetchPRForBranch: async (repoPath, branch, options): Promise<PRInfo | null> => {
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
}
})
@@ -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<StateCreator<AppState>>[0]
get: Parameters<StateCreator<AppState>>[1]
repoPath: string
branch: string
options: Parameters<GitHubSlice['fetchPRForBranch']>[2]
repo: Repo | undefined
repoId: string | undefined
requestSettings: AppState['settings']
cacheKey: string
cached: CacheEntry<PRInfo> | undefined
linkedPRNumber: number | null
fallbackPRNumber: number | null
fallbackPRSource: GitHubPRFallbackSource | null
generation: number
requestStartedAt: number
requestStartedHostedReviewEntry: AppState['hostedReviewCache'][string] | undefined
requestStartedPRRefreshToken: PRRefreshStateClearToken | null
}): Promise<PRInfo | null> {
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<PRRefreshOutcome | PRInfo | null>(
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
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'applyGitHubPRRefreshEvent'> => ({
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
})
}
)
}
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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 }))
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'refreshAllGitHub' | 'refreshGitHubForWorktree'> => ({
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<Repo, 'connectionId' | 'executionHostId'>
)
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<AppState> = {}
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 })
}
}
})
@@ -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<GitHubPRRefreshCandidate, 'cacheKey' | 'executionHostId'>
): 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<unknown>
): 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<Pick<AppState, 'repos'>>,
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<Repo, 'connectionId' | 'executionHostId'> | undefined
): string {
if (repo?.executionHostId || repo?.connectionId) {
return getRepoExecutionHostId(repo)
}
return getSettingsFocusedExecutionHostId(settings)
}
@@ -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<PRInfo | null>
force: boolean
generation: number
lookupHintKey: string
}
export type InflightChecks = {
promise: Promise<PRCheckDetail[]>
force: boolean
noCache: boolean
}
export type InflightWorkItems = {
promise: Promise<readonly GitHubWorkItem[]>
force: boolean
noCache: boolean
requireComplete: boolean
}
export const inflightPRRequests = new Map<string, InflightPR>()
export const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
export const inflightChecksRequests = new Map<string, InflightChecks>()
export const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
export const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
export const inflightProjectViewRequests = new Map<
string,
{ promise: Promise<GetProjectViewTableResult>; force: boolean }
>()
export const prRequestGenerations = new Map<string, number>()
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<void> {
if (providerRequestsInFlight < PROVIDER_REQUEST_CONCURRENCY) {
providerRequestsInFlight += 1
return
}
await new Promise<void>((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)
}
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'resolveReviewThread'> => ({
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<boolean>(
{ 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
}
})
@@ -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<string, CacheEntry<PRInfo>>
issueCache: Record<string, CacheEntry<IssueInfo>>
checksCache: Record<string, CacheEntry<PRCheckDetail[]>>
commentsCache: Record<string, CacheEntry<PRComment[]>>
prRefreshSequences: Record<string, number>
prRefreshStates: Record<string, PRRefreshState>
prVisibleRefreshGeneration: number
// Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results.
workItemsCache: Record<string, CacheEntry<readonly GitHubWorkItem[]>>
fetchPRForBranch: (
repoPath: string,
branch: string,
options?: RepoScopedFetchOptions & {
worktreeId?: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
}
) => Promise<PRInfo | null>
fetchIssue: (
repoPath: string,
number: number,
options?: RepoScopedFetchOptions
) => Promise<IssueInfo | null>
fetchPRChecks: (
repoPath: string,
prNumber: number,
branch?: string,
headSha?: string,
prRepo?: GitHubOwnerRepo | null,
options?: RepoScopedFetchOptions
) => Promise<PRCheckDetail[]>
fetchPRCheckDetails: (
repoPath: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
},
options?: RepoScopedFetchOptions
) => Promise<PRCheckRunDetails | null>
fetchPRComments: (
repoPath: string,
prNumber: number,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<PRComment[]>
addPRConversationComment: (
repoPath: string,
prNumber: number,
body: string,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<GitHubCommentResult>
addPRReviewCommentReply: (
repoPath: string,
prNumber: number,
commentId: number,
body: string,
options?: RepoScopedFetchOptions & {
prRepo?: GitHubOwnerRepo | null
threadId?: string
path?: string
line?: number
}
) => Promise<GitHubCommentResult>
setPRCommentReaction: (
repoPath: string,
prNumber: number,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<boolean>
resolveReviewThread: (
repoPath: string,
prNumber: number,
threadId: string,
resolve: boolean,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<boolean>
initGitHubCache: () => Promise<void>
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<readonly GitHubWorkItem[]>
/**
* 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<FetchOptions, 'noCache' | 'requireComplete'>
) => 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<GitHubWorkItem>,
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<void>
evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void
// ── ProjectV2 view cache ─────────────────────────────────────────────
projectViewCache: Record<string, ProjectViewCacheEntry<GitHubProjectTable>>
fetchProjectViewTable: (
args: GetProjectViewTableArgs,
options?: FetchOptions
) => Promise<GetProjectViewTableResult>
updateProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string,
value: GitHubProjectFieldMutationValue
) => Promise<GitHubProjectMutationResult>
clearProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string
) => Promise<GitHubProjectMutationResult>
patchProjectIssueOrPr: (
cacheKey: string,
rowId: string,
updates: ProjectRowContentUpdate
) => Promise<GitHubProjectMutationResult>
patchProjectRowIssueType: (
cacheKey: string,
rowId: string,
issueType: { id: string; name: string; color: string | null; description: string | null } | null
) => Promise<GitHubProjectMutationResult>
/** 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
}
@@ -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<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'refreshGitHubForWorktreeIfStale'> => ({
// 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 })
}
}
}
})
@@ -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<StateCreator<AppState>>[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(() => {})
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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<readonly GitHubWorkItem[]> => {
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
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'patchWorkItem' | 'setIssueSourcePreference' | 'evictGitHubRepoCaches'> => ({
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<string, CacheEntry<readonly GitHubWorkItem[]>> = {}
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<AppState> = {}
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
})
}
})
@@ -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<Repo, 'connectionId' | 'executionHostId'> | 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<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
return settingsForGitHubRepoOwner(settings, repo)
}
export function getWorkItemsCacheKeyForOwner(
state: Partial<Pick<AppState, 'repos' | 'settings'>>,
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<Repo, 'connectionId' | 'executionHostId'> | 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<Repo, 'connectionId' | 'executionHostId'> | 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<Repo, 'connectionId' | 'executionHostId'> | 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<Repo, 'connectionId' | 'executionHostId'> | 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<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>> {
if (context.target.kind === 'environment') {
return callRuntimeRpc<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>(
{ 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<number> {
if (context.target.kind === 'environment') {
return callRuntimeRpc<number>(
{ 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
}
@@ -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<string, WorktreeLookupEntry>
repoHostIdsByRepoId: Map<string, Set<string>>
}
const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
const EMPTY_WORKTREE_REPOS: AppState['repos'] = []
export function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex {
const byId = new Map<string, WorktreeLookupEntry>()
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<string, Set<string>>()
for (const repo of state.repos ?? []) {
let hostIds = repoHostIdsByRepoId.get(repo.id)
if (!hostIds) {
hostIds = new Set<string>()
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<Worktree, 'linkedPR' | 'branch' | 'head' | 'isBare' | 'isArchived'> | 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<Worktree, 'linkedPR' | 'branch' | 'head' | 'isBare' | 'isArchived'> | 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
}
}
@@ -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'
@@ -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<AppState>)
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({
@@ -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'
@@ -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<IssueInfo | null>()
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)
})
})
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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<PRComment[]>()
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()
@@ -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<GetProjectViewTableResult>()
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<GetProjectViewTableResult>()
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({})
})
})
@@ -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<AppState>)
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()
@@ -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<never> = {
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<ListWorkItemsResult<never>>()
)
let workCall = 0
mockApi.gh.listWorkItems.mockImplementation(() => workRequests[workCall++].promise)
const projectRequest = Promise.withResolvers<GetProjectViewTableResult>()
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])
})
})
@@ -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<typeof createTestStore>): 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<AppState>)
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<AppState>)
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<boolean>()
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())
})
})
@@ -63,6 +63,9 @@ export const mockApi = {
updatePullRequestBySlug: stubMock(),
updateIssueTypeBySlug: stubMock()
},
repos: {
update: stubMock().mockResolvedValue(undefined)
},
hostedReview: {
forBranch: stubMock().mockResolvedValue(null),
getCreationEligibility: stubMock(),
@@ -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,
@@ -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,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'