mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
refactor(renderer): split GitHub store slice
This commit is contained in:
@@ -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
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
Reference in New Issue
Block a user