Show accurate PR status for local worktrees (#6460)

This commit is contained in:
Brennan Benson 2026-06-26 18:51:51 -07:00 committed by GitHub
parent b899aef1f9
commit d03e99c89a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 833 additions and 121 deletions

View File

@ -58,7 +58,8 @@ export function resolveHostedReviewForCheckRunDetailsFix(
branch,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const hostedReviewCacheKey = getHostedReviewCacheKey(
repo.path,
@ -66,7 +67,8 @@ export function resolveHostedReviewForCheckRunDetailsFix(
settings,
repo.id,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const pr = prCacheKey ? (store.prCache[prCacheKey]?.data ?? null) : null
const hostedReview = hostedReviewCacheKey

View File

@ -607,7 +607,8 @@ export default function ChecksPanel(): React.JSX.Element {
branch,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const hostedReviewCacheKey =
@ -618,7 +619,8 @@ export default function ChecksPanel(): React.JSX.Element {
settings,
repo.id,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const refreshContextKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}`
@ -726,7 +728,8 @@ export default function ChecksPanel(): React.JSX.Element {
prChecksCacheSuffix(prNumber, pr?.prRepo),
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const commentsCacheKey =
@ -737,7 +740,8 @@ export default function ChecksPanel(): React.JSX.Element {
prCommentsCacheSuffix(prNumber, pr?.prRepo),
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const checksFetchedAt = useAppStore((s) =>

View File

@ -1324,7 +1324,8 @@ function SourceControlInner(): React.JSX.Element {
settings,
activeRepo.id,
activeRepo.connectionId,
activeRepo.executionHostId
activeRepo.executionHostId,
true
)
: null
const hostedReviewEntry = hostedReviewCacheKey
@ -1338,7 +1339,8 @@ function SourceControlInner(): React.JSX.Element {
branchName,
settings,
activeRepo.connectionId,
activeRepo.executionHostId
activeRepo.executionHostId,
true
)
: null
const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null

View File

@ -40,7 +40,8 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat
branch,
state.settings,
activeRepo.connectionId,
activeRepo.executionHostId
activeRepo.executionHostId,
true
)
const hostedReviewCacheKey = getHostedReviewCacheKey(
activeRepo.path,
@ -48,7 +49,8 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat
state.settings,
activeRepo.id,
activeRepo.connectionId,
activeRepo.executionHostId
activeRepo.executionHostId,
true
)
const hostedReview = state.hostedReviewCache?.[hostedReviewCacheKey]?.data ?? null
if (hostedReview && hostedReview.provider !== 'github') {

View File

@ -223,7 +223,8 @@ function getGitHubChecksEntry(
prChecksCacheSuffix(review.number, prRepo, review.headSha),
args.settings,
args.repo.connectionId,
args.repo.executionHostId
args.repo.executionHostId,
true
)
const withoutHead = getGitHubRepoCacheKey(
args.repo.path,
@ -231,7 +232,8 @@ function getGitHubChecksEntry(
prChecksCacheSuffix(review.number, prRepo),
args.settings,
args.repo.connectionId,
args.repo.executionHostId
args.repo.executionHostId,
true
)
return args.checksCache[withHead] ?? args.checksCache[withoutHead]
}
@ -246,7 +248,8 @@ function getHostedReviewKey(
args.settings,
args.repo.id,
args.repo.connectionId,
args.repo.executionHostId
args.repo.executionHostId,
true
)
}
@ -257,7 +260,8 @@ function getPRKey(args: BuildParentPrChecksRowsArgs & { repo: Repo }, branch: st
branch,
args.settings,
args.repo.connectionId,
args.repo.executionHostId
args.repo.executionHostId,
true
)
}

View File

@ -21,6 +21,7 @@ const cacheTimerMocks = vi.hoisted(() => ({
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports']
let hostedReviewCache: Record<string, unknown> = {}
let issueCache: Record<string, unknown> = {}
let projectGroups: unknown[] = []
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial<GlobalSettings> | null = { compactWorktreeCards: true }
@ -39,7 +40,7 @@ vi.mock('@/store', () => ({
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache,
issueCache: {},
issueCache,
linearIssueCache: {},
openModal,
openTaskPage,
@ -185,6 +186,7 @@ describe('WorktreeCard compact hover details', () => {
vi.clearAllMocks()
worktreeCardProperties = ['status', 'ports']
hostedReviewCache = {}
issueCache = {}
projectGroups = []
workspacePortScan = null
settings = { compactWorktreeCards: true }
@ -296,6 +298,38 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).not.toContain('data-worktree-card-meta-row=""')
}, 30_000)
it('reads linked issue details from the local repo-owner cache while a runtime is focused', async () => {
settings = {
activeRuntimeEnvironmentId: 'env-1',
compactWorktreeCards: true,
experimentalNewWorktreeCardStyle: true
}
worktreeCardProperties = ['status']
issueCache = {
'repo-1::123': {
data: { number: 123, title: 'Local owner issue', state: 'open', url: null },
fetchedAt: Date.now()
},
'runtime:env-1::repo-1::123': {
data: { number: 123, title: 'Runtime fallback issue', state: 'open', url: null },
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedIssue: 123 })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Local owner issue')
expect(markup).not.toContain('Runtime fallback issue')
expect(markup).not.toContain('Loading issue')
}, 30_000)
it('shows selected task and note metadata on the compact card title row', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment']

View File

@ -20,6 +20,7 @@ const updateWorktreeMeta = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
let hostedReviewCache: Record<string, unknown> = {}
let issueCache: Record<string, unknown> = {}
let prCache: Record<string, unknown> = {}
let workspacePortScan: WorkspacePortScanResult | null = null
let settings: Partial<GlobalSettings> | null = null
@ -33,7 +34,7 @@ vi.mock('@/store', () => ({
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache,
issueCache: {},
issueCache,
linearIssueCache: {},
openModal,
prCache,
@ -161,6 +162,7 @@ describe('WorktreeCard linked PR display', () => {
vi.clearAllMocks()
worktreeCardProperties = ['status']
hostedReviewCache = {}
issueCache = {}
prCache = {}
workspacePortScan = null
settings = null
@ -669,6 +671,42 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('lucide-git-branch')
})
it('reads the local branch PR cache for a known local repo while a runtime is focused', async () => {
settings = {
activeRuntimeEnvironmentId: 'env-win',
experimentalNewWorktreeCardStyle: true
}
worktreeCardProperties = ['status']
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6341,
title: 'Keep local PR status visible',
state: 'open',
checksStatus: 'pending'
}),
fetchedAt: Date.now()
},
'runtime:env-win::repo-1::feature/local-branch': {
data: null,
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('PR checks: Pending')
expect(markup).not.toContain('Branch')
expect(markup).not.toContain('lucide-git-branch')
})
it('keeps the detailed right-side PR badge during a transient hosted-review miss', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']

View File

@ -412,7 +412,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
settings,
repo.id,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const prCacheKey =
@ -423,7 +424,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
branch,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const issueCacheKey =
@ -434,7 +436,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree.linkedIssue,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
// Why: use 'all' to fetch from all Linear workspaces. The issue might belong

View File

@ -172,7 +172,8 @@ function getAttachedWorktreePrDisplay({
settings,
repo.id,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const hostedReviewEntry = hostedReviewCache?.[hostedReviewCacheKey] as
| HostedReviewCacheEntry
@ -241,10 +242,10 @@ function getCachedGitHubPr({
branch,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const canUseLegacyPRCache =
!settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId && !repo.executionHostId
const canUseLegacyPRCache = !repo.connectionId && !repo.executionHostId
const legacyRepoScopedCacheKey = canUseLegacyPRCache
? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch)
: ''

View File

@ -46,7 +46,8 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
issueNumber,
s.settings,
issueRepo.connectionId,
issueRepo.executionHostId
issueRepo.executionHostId,
true
)
]?.data?.url ?? null
)

View File

@ -165,7 +165,7 @@ describe('getPRGroupKey', () => {
expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('closed')
})
it('does not fall back to local PR cache while runtime scoped data is loading', () => {
it('uses local PR cache for a known local repo while a runtime is focused', () => {
const prCache = {
'repo-1::feature/super-critical': {
data: { state: 'merged' }
@ -176,7 +176,7 @@ describe('getPRGroupKey', () => {
getPRGroupKey(worktree, repoMap, prCache, {
activeRuntimeEnvironmentId: 'env-1'
} as never)
).toBe('in-progress')
).toBe('done')
})
it('uses SSH-scoped PR cache entries instead of local entries for SSH repos', () => {

View File

@ -361,11 +361,11 @@ export function getPRGroupKey(
branch,
settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
const canUseLegacyPRCache =
repo !== undefined && !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId
const canUseLegacyPRCache = repo !== undefined && !repo.connectionId && !repo.executionHostId
const legacyRepoScopedCacheKey =
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : ''
const legacyPathScopedCacheKey =

View File

@ -183,7 +183,7 @@ function getBranchStatus(
return parts.join(', ')
}
function getWorkspaceDecisionDetails(
export function getWorkspaceDecisionDetails(
worktree: WorkspaceSpaceWorktree,
inputs: WorkspaceDecisionInputs
): WorkspaceDecisionDetails {
@ -197,11 +197,15 @@ function getWorkspaceDecisionDetails(
const branch = workspaceRecord
? branchDisplayName(workspaceRecord.branch)
: getWorkspaceSpaceBranchLabel(worktree)
const repo = inputs.repoMap.get(worktree.repoId)
const reviewCacheKey = getHostedReviewCacheKey(
worktree.repoPath,
branch,
inputs.settings,
worktree.repoId
worktree.repoId,
repo?.connectionId,
repo?.executionHostId,
repo !== undefined
)
const hostedReview = inputs.hostedReviewCache[reviewCacheKey]?.data
const linkedPR = workspaceRecord?.linkedPR ?? null
@ -214,7 +218,6 @@ function getWorkspaceDecisionDetails(
? `PR #${linkedPR}`
: null
const linkedIssue = workspaceRecord?.linkedIssue ?? null
const repo = inputs.repoMap.get(worktree.repoId)
const issue =
linkedIssue && repo
? inputs.issueCache[
@ -224,7 +227,8 @@ function getWorkspaceDecisionDetails(
linkedIssue,
inputs.settings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
]?.data
: null

View File

@ -16,7 +16,9 @@ import {
resolveWorkspaceSpaceTreemapZoomWorktreeId,
sortWorkspaceSpaceRows
} from './workspace-space-presentation'
import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { Repo, Worktree } from '../../../../shared/types'
function row(overrides: Partial<WorkspaceSpaceWorktree>): WorkspaceSpaceWorktree {
return {
@ -74,6 +76,69 @@ function activeAgent(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntr
}
}
function repo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo',
path: '/repo',
displayName: 'Repo',
badgeColor: '#999999',
addedAt: 1,
...overrides
}
}
function worktreeRecord(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'wt',
repoId: 'repo',
path: '/workspace',
displayName: 'workspace',
branch: 'refs/heads/feature/local',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function decisionInputs(
overrides: Partial<Parameters<typeof getWorkspaceDecisionDetails>[1]> = {}
): Parameters<typeof getWorkspaceDecisionDetails>[1] {
const defaultRepo = repo()
const defaultWorktree = worktreeRecord()
return {
repoMap: new Map([[defaultRepo.id, defaultRepo]]),
worktreeMap: new Map([[defaultWorktree.id, defaultWorktree]]),
tabsByWorktree: {},
ptyIdsByTabId: {},
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimePaneTitlesByTabId: {},
retainedAgentsByPaneKey: {},
openFiles: [],
editorDrafts: {},
browserTabsByWorktree: {},
gitStatusByWorktree: {},
remoteStatusesByWorktree: {},
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
settings: null,
activeWorktreeId: null,
now: 1_000,
...overrides
}
}
describe('workspace space presentation helpers', () => {
it('sorts rows by the selected key and direction', () => {
const rows = [
@ -238,6 +303,37 @@ describe('workspace space presentation helpers', () => {
).toBe(0)
})
it('reads review and issue details from local owner cache while a runtime is focused', () => {
const details = getWorkspaceDecisionDetails(
row({ branch: 'refs/heads/feature/local' }),
decisionInputs({
settings: { activeRuntimeEnvironmentId: 'env-1' },
hostedReviewCache: {
'local::repo::feature/local': {
data: { number: 12, state: 'open', status: 'success', title: 'Local owner PR' }
},
'runtime:env-1::repo::feature/local': {
data: { number: 99, state: 'open', status: 'failure', title: 'Runtime fallback PR' }
}
},
issueCache: {
'repo::123': {
data: { number: 123, title: 'Local owner issue', state: 'open' }
},
'runtime:env-1::repo::123': {
data: { number: 123, title: 'Runtime fallback issue', state: 'closed' }
}
},
worktreeMap: new Map([
['wt', worktreeRecord({ branch: 'refs/heads/feature/local', linkedIssue: 123 })]
])
})
)
expect(details.reviewLabel).toBe('PR #12 Open, success')
expect(details.issueLabel).toBe('#123 open: Local owner issue')
})
it('counts migration-unsupported agent entries by worktree id', () => {
const count = countWorkspaceSpaceActiveAgents({
worktreeId: 'wt',

View File

@ -13,10 +13,11 @@ export function getGitHubRepoCacheKey(
suffix: string,
settings?: RuntimeFocusSettings,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
const owner = repoId ?? repoPath
const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId)
const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner)
// Why: runtime/SSH lookups can observe different remotes than the local repo
// path, so cache keys include the repo's owning execution boundary.
if (scope) {
@ -28,18 +29,27 @@ export function getGitHubRepoCacheKey(
function getGitHubCacheHostScope(
settings?: RuntimeFocusSettings,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string | null {
const hostId = normalizeExecutionHostId(executionHostId)
if (hostId) {
return hostId === LOCAL_EXECUTION_HOST_ID ? null : hostId
}
const sshConnectionId = connectionId?.trim()
if (sshConnectionId) {
return toSshExecutionHostId(sshConnectionId)
}
// Why: an existing repo with no remote/runtime owner is local; only missing
// owner context should inherit the focused runtime fallback.
if (hasRepoOwner) {
return null
}
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim()
if (runtimeEnvironmentId) {
return `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
}
const sshConnectionId = connectionId?.trim()
return sshConnectionId ? toSshExecutionHostId(sshConnectionId) : null
return null
}
export function getLegacyGitHubRepoCacheKey(
@ -56,9 +66,18 @@ export function getGitHubPRCacheKey(
branch: string,
settings?: RuntimeFocusSettings,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId)
return getGitHubRepoCacheKey(
repoPath,
repoId,
branch,
settings,
connectionId,
executionHostId,
hasRepoOwner
)
}
export function getLegacyGitHubPRCacheKey(

View File

@ -44,4 +44,36 @@ describe('syncPRChecksStatus', () => {
])
expect(result?.prCache?.['repo-id::main']?.data?.checksStatus).toBe('success')
})
it('updates the local repo key while a runtime is focused when repo owner is known', () => {
const state = {
prCache: {
'repo-id::main': {
fetchedAt: 0,
data: { checksStatus: 'neutral' as const }
},
'runtime:env-win::repo-id::main': {
fetchedAt: 0,
data: { checksStatus: 'neutral' as const }
}
}
} as unknown as AppState
const result = syncPRChecksStatus(
state,
'/repo',
'repo-id',
'main',
[{ name: 'build', status: 'completed', conclusion: 'success', url: null }],
undefined,
undefined,
{ activeRuntimeEnvironmentId: 'env-win' } as AppState['settings'],
null,
null,
true
)
expect(result?.prCache?.['repo-id::main']?.data?.checksStatus).toBe('success')
expect(result?.prCache?.['runtime:env-win::repo-id::main']?.data?.checksStatus).toBe('neutral')
})
})

View File

@ -44,7 +44,8 @@ export function syncPRChecksStatus(
prRepo?: GitHubOwnerRepo | null,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): Partial<AppState> | null {
const normalized = branch ? normalizeBranchName(branch) : ''
if (!normalized) {
@ -57,7 +58,8 @@ export function syncPRChecksStatus(
normalized,
settings,
connectionId,
executionHostId
executionHostId,
hasRepoOwner
)
const prEntry = state.prCache[prCacheKey]
if (!prEntry?.data) {

View File

@ -76,6 +76,17 @@ describe('enqueueGitHubPRRefresh host guard', () => {
expect(enqueuePRRefresh).toHaveBeenCalledTimes(1)
})
it('enqueues the local handler for a known local repo while a runtime is focused', () => {
const store = createTestStore()
seed(store, { id: 'local-1', path: '/Users/me/code/local-1', name: 'local-1', kind: 'git' })
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-win' } as never })
store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80)
expect(enqueuePRRefresh).toHaveBeenCalledTimes(1)
expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalled()
})
it('enqueues the local handler for a repo with an explicit local executionHostId', () => {
const store = createTestStore()
seed(store, {

View File

@ -630,7 +630,15 @@ describe('createGitHubSlice.fetchPRChecks', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
repos: [
{
id: repoId,
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
prCache: {
[runtimePrCacheKey]: {
data: makePR({ checksStatus: 'pending' }),
@ -656,6 +664,51 @@ describe('createGitHubSlice.fetchPRChecks', () => {
expect(store.getState().prCache[runtimePrCacheKey]?.data?.checksStatus).toBe('success')
})
it('keeps known local repo checks on local cache keys when a runtime is focused', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/local-checks'
const localPrCacheKey = `${repoId}::${branch}`
const localChecksCacheKey = `${repoId}::pr-checks::12`
const runtimeChecksCacheKey = `runtime:env-1::${repoId}::pr-checks::12`
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
prCache: {
[localPrCacheKey]: {
data: makePR({ checksStatus: 'pending' }),
fetchedAt: 1
}
}
} as unknown as Partial<AppState>)
mockApi.gh.prChecks.mockResolvedValueOnce([
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
])
await store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.gh.prChecks).toHaveBeenCalledWith({
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: true,
sourceContext: undefined
})
expect(store.getState().checksCache[localChecksCacheKey]?.data).toEqual([
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
])
expect(store.getState().checksCache[runtimeChecksCacheKey]).toBeUndefined()
expect(store.getState().prCache[localPrCacheKey]?.data?.checksStatus).toBe('success')
})
it('marks the PR cache entry as failure when any check fails', async () => {
const store = createTestStore()
const repoPath = '/repo'
@ -1064,7 +1117,15 @@ describe('createGitHubSlice.fetchPRComments', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }]
repos: [
{
id: repoId,
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
} as unknown as Partial<AppState>)
await store.getState().fetchPRComments(repoPath, 12, {
@ -1093,6 +1154,42 @@ describe('createGitHubSlice.fetchPRComments', () => {
).toBeUndefined()
})
it('keeps known local repo comments on local cache keys when a runtime is focused', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }]
} as unknown as Partial<AppState>)
mockApi.gh.prComments.mockResolvedValueOnce([
{ id: 1, author: 'local', authorAvatarUrl: '', body: '', createdAt: '', url: '' }
])
await store.getState().fetchPRComments(repoPath, 12, {
force: true,
repoId,
prRepo: { owner: 'Acme', repo: 'Widgets' }
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.gh.prComments).toHaveBeenCalledWith({
repoPath,
repoId,
prNumber: 12,
prRepo: { owner: 'Acme', repo: 'Widgets' },
noCache: true,
sourceContext: undefined
})
expect(
store.getState().commentsCache[`${repoId}::pr-comments::acme/widgets::12`]?.data?.[0].author
).toBe('local')
expect(
store.getState().commentsCache[`runtime:env-1::${repoId}::pr-comments::acme/widgets::12`]
).toBeUndefined()
})
it('routes explicit source-context PR comments through the source runtime', async () => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-source-comments',
@ -1249,7 +1346,15 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }]
repos: [
{
id: repoId,
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
} as unknown as Partial<AppState>)
await store.getState().fetchPRCheckDetails(
@ -1277,6 +1382,39 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => {
})
expect(mockApi.gh.prCheckDetails).not.toHaveBeenCalled()
})
it('loads known local repo check details through local IPC when a runtime is focused', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }]
} as unknown as Partial<AppState>)
await store.getState().fetchPRCheckDetails(
repoPath,
{
checkRunId: 123,
checkName: 'build',
prRepo: { owner: 'Acme', repo: 'Widgets' }
},
{ repoId }
)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.gh.prCheckDetails).toHaveBeenCalledWith({
repoPath,
repoId,
checkRunId: 123,
workflowRunId: undefined,
checkName: 'build',
url: undefined,
prRepo: { owner: 'Acme', repo: 'Widgets' },
sourceContext: undefined
})
})
})
describe('createGitHubSlice PR comment mutations', () => {
@ -1428,7 +1566,15 @@ describe('createGitHubSlice PR comment mutations', () => {
const repoId = 'repo-id'
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }]
repos: [
{
id: repoId,
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
} as unknown as Partial<AppState>)
await store.getState().addPRReviewCommentReply(repoPath, 12, 99, 'reply', {
@ -2937,8 +3083,28 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings']
const runtimeHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, settings, repoId)
const localHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId)
const localChecksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}`
const runtimeChecksCacheKey = `runtime:env-1::${repoId}::${prChecksCacheSuffix(
12,
null,
'head-oid'
)}`
store.setState({ settings } as Partial<AppState>)
store.setState({
settings,
checksCache: {
[localChecksCacheKey]: {
data: [{ name: 'test', status: 'completed', conclusion: 'failure', url: null }],
fetchedAt: 1,
headSha: 'head-oid'
},
[runtimeChecksCacheKey]: {
data: [{ name: 'test', status: 'completed', conclusion: 'success', url: null }],
fetchedAt: 1,
headSha: 'head-oid'
}
}
} as Partial<AppState>)
store.getState().applyGitHubPRRefreshEvent({
sequence: 1,
@ -2946,14 +3112,15 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
reason: 'visible',
outcome: {
kind: 'found',
pr: makePR({ number: 12, title: 'Local PR status' }),
pr: makePR({ number: 12, title: 'Local PR status', checksStatus: 'pending' }),
fetchedAt: 2
}
})
expect(store.getState().prCache[cacheKey]?.data).toMatchObject({
number: 12,
title: 'Local PR status'
title: 'Local PR status',
checksStatus: 'failure'
})
expect(store.getState().prRefreshSequences[cacheKey]).toBe(1)
expect(store.getState().hostedReviewCache[localHostedReviewCacheKey]?.data).toMatchObject({
@ -3653,12 +3820,23 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
{
activeRuntimeEnvironmentId: 'env-1'
} as AppState['settings'],
'repo-1'
'repo-1',
null,
'runtime:env-1',
true
)
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
repos: [
{
id: 'repo-1',
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
groupBy: 'pr-status',
worktreeCardProperties: ['status'],
worktreesByRepo: {
@ -3934,7 +4112,15 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
repos: [
{
id: 'repo-1',
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
activeWorktreeId: 'wt-1',
@ -4062,7 +4248,15 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
repos: [
{
id: 'repo-1',
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'repo-1': [
{

View File

@ -214,7 +214,7 @@ function settingsForGitHubRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
if (!repo) {
return settings
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
@ -230,6 +230,16 @@ function settingsForGitHubRepoOwner(
: ({ activeRuntimeEnvironmentId: null } as AppState['settings'])
}
function settingsForGitHubFocusedRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
return settingsForGitHubRepoOwner(settings, repo)
}
function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string {
const explicitHostId = normalizeExecutionHostId(alias.executionHostId)
if (explicitHostId) {
@ -249,7 +259,7 @@ function findRepoForGitHubOwner(
)
}
function getGitHubRepoOwnerHostId(
function getGitHubFocusedRepoOwnerHostId(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): string {
@ -271,7 +281,7 @@ function getWorkItemsCacheKeyForOwner(
repoId,
limit,
query,
repo ? getGitHubRepoOwnerHostId(state.settings ?? null, repo) : undefined
repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined
)
}
@ -284,7 +294,7 @@ function getGitHubWorkItemSourceHostId(
return sourceContext.hostId
}
return repo
? (normalizeExecutionHostId(getGitHubRepoOwnerHostId(state.settings, repo)) ?? undefined)
? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined)
: undefined
}
@ -303,6 +313,20 @@ 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)
}
function getGitHubRepoSourceSettings(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
sourceContext?: TaskSourceContext | null
): AppState['settings'] {
if (sourceContext?.provider === 'github') {
return {
@ -761,7 +785,8 @@ export function issueCacheKey(
issueNumber: number | string,
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
return getGitHubRepoCacheKey(
repoPath,
@ -769,7 +794,8 @@ export function issueCacheKey(
String(issueNumber),
settings,
connectionId,
executionHostId
executionHostId,
hasRepoOwner
)
}
@ -779,9 +805,18 @@ function runtimeScopedRepoCacheKey(
suffix: string,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId, executionHostId)
return getGitHubRepoCacheKey(
repoPath,
repoId,
suffix,
settings,
connectionId,
executionHostId,
hasRepoOwner
)
}
function sourceScopedRepoCacheKey(
@ -791,7 +826,8 @@ function sourceScopedRepoCacheKey(
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null,
sourceContext?: TaskSourceContext | null
sourceContext?: TaskSourceContext | null,
hasRepoOwner = false
): string {
if (sourceContext?.provider === 'github') {
return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}`
@ -802,7 +838,8 @@ function sourceScopedRepoCacheKey(
suffix,
settings,
connectionId,
executionHostId
executionHostId,
hasRepoOwner
)
}
@ -812,9 +849,18 @@ function prCacheKey(
branch: string,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId)
return getGitHubPRCacheKey(
repoPath,
repoId,
branch,
settings,
connectionId,
executionHostId,
hasRepoOwner
)
}
function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] {
@ -978,7 +1024,8 @@ function buildPRRefreshCandidate(
branch,
settingsForGitHubRepoOwner(state.settings, repo),
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const cachedPR = state.prCache[cacheKey]?.data ?? null
const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber(
@ -987,7 +1034,8 @@ function buildPRRefreshCandidate(
repo.id,
branch,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const cachedFallbackPRNumber = cachedPR?.number ?? null
const fallbackPRNumber =
@ -1037,7 +1085,8 @@ function githubHostedReviewFallbackPRNumber(
repoId: string | undefined,
branch: string,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): number | null {
const hostedReviewCacheKey = getHostedReviewCacheKey(
repoPath,
@ -1045,7 +1094,8 @@ function githubHostedReviewFallbackPRNumber(
state.settings,
repoId,
connectionId,
executionHostId
executionHostId,
hasRepoOwner
)
const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data
return hostedReview?.provider === 'github' ? hostedReview.number : null
@ -1109,6 +1159,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: {
repoId?: string
connectionId?: string | null
executionHostId?: string | null
hasRepoOwner?: boolean
pr: PRInfo | null
fetchedAt: number
linkedPRNumber?: number | null
@ -1123,7 +1174,8 @@ function syncHostedReviewCacheFromGitHubPRResult(args: {
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
args.executionHostId,
args.hasRepoOwner === true
)
if (
args.requestStartedAt !== undefined &&
@ -1294,6 +1346,7 @@ function setGitHubPRResultCaches(
repoId?: string
connectionId?: string | null
executionHostId?: string | null
hasRepoOwner?: boolean
pr: PRInfo | null
fetchedAt: number
worktreeId?: string
@ -1312,6 +1365,7 @@ function setGitHubPRResultCaches(
repoId: args.repoId,
connectionId: args.connectionId,
executionHostId: args.executionHostId,
hasRepoOwner: args.hasRepoOwner,
pr: args.pr,
fetchedAt: args.fetchedAt,
linkedPRNumber: args.linkedPRNumber,
@ -1326,7 +1380,8 @@ function setGitHubPRResultCaches(
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
args.executionHostId,
args.hasRepoOwner === true
)
const nextPRCache = applyPRCacheResult(
state.prCache,
@ -1368,6 +1423,7 @@ function applyGitHubPRResultToCaches(args: {
repoId?: string
connectionId?: string | null
executionHostId?: string | null
hasRepoOwner?: boolean
pr: PRInfo | null
fetchedAt: number
state: AppState
@ -1389,6 +1445,7 @@ function applyGitHubPRResultToCaches(args: {
repoId: args.repoId,
connectionId: args.connectionId,
executionHostId: args.executionHostId,
hasRepoOwner: args.hasRepoOwner,
pr: args.pr,
fetchedAt: args.fetchedAt,
linkedPRNumber: args.linkedPRNumber,
@ -1403,7 +1460,8 @@ function applyGitHubPRResultToCaches(args: {
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
args.executionHostId,
args.hasRepoOwner === true
)
return {
prCache: applyPRCacheResult(
@ -2813,7 +2871,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
branch,
requestSettings,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
const cached = get().prCache[cacheKey]
const hostedReviewCacheKey = getHostedReviewCacheKey(
@ -2822,7 +2881,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repoId,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
// Why: if a prior caller without a linkedPR cached `null` for this branch,
// the worktree-card lookup (which has a linked PR fallback) would otherwise
@ -2836,7 +2896,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
repoId,
branch,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
const fallbackPRNumber =
linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null
@ -2954,6 +3015,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
repoId,
connectionId: repo?.connectionId,
executionHostId: repo?.executionHostId,
hasRepoOwner: repo !== undefined,
pr,
fetchedAt: outcome.fetchedAt,
worktreeId: options?.worktreeId,
@ -3016,7 +3078,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
fetchIssue: async (repoPath, number, options) => {
const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3028,7 +3090,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
const cached = get().issueCache[cacheKey]
if (isFresh(cached)) {
@ -3102,7 +3165,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3114,7 +3177,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
const legacyCacheKey = headSha
? sourceScopedRepoCacheKey(
@ -3124,7 +3188,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
: cacheKey
const inflightKey = cacheKey
@ -3146,7 +3211,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
if (prStatusUpdate) {
set(prStatusUpdate)
@ -3218,7 +3284,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
if (prStatusUpdate?.prCache) {
nextState.prCache = prStatusUpdate.prCache
@ -3253,7 +3320,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3296,7 +3363,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3308,7 +3375,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
const cached = get().commentsCache[cacheKey]
if (!options?.force && isFresh(cached)) {
@ -3374,7 +3442,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3386,7 +3454,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
const requestContext = getGitHubWorkItemRequestContext(
get(),
@ -3452,7 +3521,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3464,7 +3533,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
const requestContext = getGitHubWorkItemRequestContext(
get(),
@ -3542,7 +3612,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
@ -3554,7 +3624,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
options?.sourceContext,
repo !== undefined
)
// Optimistic update: toggle isResolved on all comments in this thread immediately
@ -3740,7 +3811,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
aliasExecutionHostId,
true
)
]
: []),
@ -3750,7 +3822,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId,
aliasExecutionHostId
aliasExecutionHostId,
true
)
]
: []),
@ -3762,7 +3835,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
aliasExecutionHostId,
true
)
]
: []),
@ -3772,7 +3846,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId,
aliasExecutionHostId
aliasExecutionHostId,
true
),
`${alias.repoPath}::pr-checks::${pr.number}`
]
@ -3807,6 +3882,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
repoId: alias.repoId,
connectionId: alias.connectionId,
executionHostId: aliasExecutionHostId,
hasRepoOwner: true,
pr: data,
fetchedAt: event.outcome.fetchedAt,
state: s,
@ -3834,7 +3910,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
s.settings,
alias.repoId,
alias.connectionId,
aliasExecutionHostId
aliasExecutionHostId,
true
)
setPRRefreshStartedHostedReviewEntry(
prRefreshStartedEntryKey(event.sequence, alias.cacheKey),
@ -3947,7 +4024,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
wt.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
@ -4014,7 +4092,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
worktree.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
: ''
@ -4257,7 +4336,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
worktree.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
repo.executionHostId,
true
)
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {

View File

@ -20,16 +20,18 @@ export function getHostedReviewCacheKey(
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
repoId?: string | null,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId)
const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner)
return `${scope}::${repoId ?? repoPath}::${branch}`
}
function getHostedReviewCacheHostScope(
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
connectionId?: string | null,
executionHostId?: string | null
executionHostId?: string | null,
hasRepoOwner = false
): string {
const hostId = normalizeExecutionHostId(executionHostId)
if (hostId) {
@ -39,6 +41,11 @@ function getHostedReviewCacheHostScope(
if (sshConnectionId) {
return toSshExecutionHostId(sshConnectionId)
}
// Why: a known repo owner with no SSH/runtime marker is local; absent owner
// context keeps the focused-runtime fallback for active-host operations.
if (hasRepoOwner) {
return 'local'
}
return getSettingsFocusedExecutionHostId(settings)
}

View File

@ -90,7 +90,15 @@ describe('hosted review cache race protection', () => {
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/race')
const cacheKey = getHostedReviewCacheKey(
'/repo',
'feature/race',
null,
'repo-1',
null,
null,
true
)
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/race')
vi.setSystemTime(200)
@ -125,7 +133,15 @@ describe('hosted review cache race protection', () => {
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/error-race')
const cacheKey = getHostedReviewCacheKey(
'/repo',
'feature/error-race',
null,
'repo-1',
null,
null,
true
)
try {
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/error-race')
@ -167,7 +183,15 @@ describe('hosted review cache race protection', () => {
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-race')
const cacheKey = getHostedReviewCacheKey(
'/repo',
'feature/same-ms-race',
null,
'repo-1',
null,
null,
true
)
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/same-ms-race')
store.setState({
@ -202,7 +226,15 @@ describe('hosted review cache race protection', () => {
}
mockApi.hostedReview.forBranch.mockResolvedValueOnce(freshReview)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-existing')
const cacheKey = getHostedReviewCacheKey(
'/repo',
'feature/same-ms-existing',
null,
'repo-1',
null,
null,
true
)
store.setState({
hostedReviewCache: {

View File

@ -139,7 +139,15 @@ describe('hosted review cache revalidation', () => {
).resolves.toEqual(review)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
const cacheKey = getHostedReviewCacheKey(
'/repo',
'feature/pr',
null,
'repo-1',
null,
null,
true
)
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
resolveRefresh(updatedReview)
@ -192,11 +200,14 @@ describe('hosted review cache revalidation', () => {
}
expect(
store.getState().hostedReviewCache[getHostedReviewCacheKey('/repo', 'feature/cache-0')]
store.getState().hostedReviewCache[
getHostedReviewCacheKey('/repo', 'feature/cache-0', null, 'repo-1', null, null, true)
]
).toBeUndefined()
expect(
store.getState().hostedReviewCache[getHostedReviewCacheKey('/repo', 'feature/cache-500')]
?.data
store.getState().hostedReviewCache[
getHostedReviewCacheKey('/repo', 'feature/cache-500', null, 'repo-1', null, null, true)
]?.data
).toMatchObject({ title: 'feature/cache-500' })
expect(Object.keys(store.getState().hostedReviewCache)).toHaveLength(500)
expect(_getHostedReviewRequestGenerationCountForTest()).toBe(0)

View File

@ -159,6 +159,30 @@ describe('hosted review slice', () => {
expect(store.getState().hostedReviewCache['local::repo-1::feature/gitlab']).toBeUndefined()
})
it('uses local hosted-review IPC for a known local repo while a runtime is focused', async () => {
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review)
const store = makeStore({
activeRuntimeEnvironmentId: 'env-win'
} as AppState['settings'])
await expect(
store.getState().fetchHostedReviewForBranch('/repo', 'feature/local', {
repoId: 'repo-1'
})
).resolves.toEqual(review)
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/repo', branch: 'feature/local' })
)
expect(store.getState().hostedReviewCache['local::repo-1::feature/local']).toMatchObject({
data: review
})
expect(
store.getState().hostedReviewCache['runtime:env-win::repo-1::feature/local']
).toBeUndefined()
})
it('routes active runtime review lookups through runtime RPC', async () => {
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review)
const store = makeStore({

View File

@ -135,7 +135,7 @@ function settingsForHostedReviewRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
if (!repo) {
return settings
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
@ -151,6 +151,16 @@ function settingsForHostedReviewRepoOwner(
: ({ activeRuntimeEnvironmentId: null } as AppState['settings'])
}
function settingsForHostedReviewActionOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
return settingsForHostedReviewRepoOwner(settings, repo)
}
export type HostedReviewSlice = {
hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>>
getHostedReviewCreationEligibility: (
@ -205,7 +215,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
getHostedReviewCreationEligibility: async (args) => {
const settings = get().settings
const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId)
const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo)
const ownerSettings = settingsForHostedReviewActionOwner(settings, repo)
const target = getActiveRuntimeTarget(ownerSettings)
if (target.kind === 'environment') {
const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args
@ -231,7 +241,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
createHostedReview: async (repoPath, input) => {
const settings = get().settings
const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId)
const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo)
const ownerSettings = settingsForHostedReviewActionOwner(settings, repo)
const target = getActiveRuntimeTarget(ownerSettings)
const { repoId: inputRepoId, ...hostedReviewInput } = input
if (target.kind === 'environment') {
@ -271,9 +281,10 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
repoPath,
branch,
ownerSettings,
options?.repoId,
repoId,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
)
const cached = get().hostedReviewCache[cacheKey]
const hintKey = linkedReviewHintKey(options)
@ -341,7 +352,8 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
branch,
ownerSettings,
repo?.connectionId,
repo?.executionHostId
repo?.executionHostId,
repo !== undefined
),
getLegacyGitHubPRCacheKey(repoPath, repoId, branch),
getLegacyGitHubPRCacheKey(repoPath, undefined, branch)

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { getGitHubPRCacheKey } from './github-cache-key'
import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
const focusedRuntime = { activeRuntimeEnvironmentId: 'env-focused' }
describe('repo owner cache identity', () => {
it('uses local PR and hosted-review keys for known local repos while a runtime is focused', () => {
expect(
getGitHubPRCacheKey('/repo', 'repo-1', 'feature/local', focusedRuntime, null, null, true)
).toBe('repo-1::feature/local')
expect(
getHostedReviewCacheKey('/repo', 'feature/local', focusedRuntime, 'repo-1', null, null, true)
).toBe('local::repo-1::feature/local')
})
it('preserves focused-runtime fallback when repo owner context is missing', () => {
expect(getGitHubPRCacheKey('/repo', 'repo-1', 'feature/local', focusedRuntime)).toBe(
'runtime:env-focused::repo-1::feature/local'
)
expect(getHostedReviewCacheKey('/repo', 'feature/local', focusedRuntime, 'repo-1')).toBe(
'runtime:env-focused::repo-1::feature/local'
)
})
it('keeps explicit runtime and SSH owners scoped to their owner host', () => {
expect(
getGitHubPRCacheKey(
'/repo',
'repo-1',
'feature/remote',
null,
null,
'runtime:env-owner',
true
)
).toBe('runtime:env-owner::repo-1::feature/remote')
expect(
getHostedReviewCacheKey('/repo', 'feature/ssh', focusedRuntime, 'repo-1', 'ssh-1', null, true)
).toBe('ssh:ssh-1::repo-1::feature/ssh')
})
})

View File

@ -19,6 +19,7 @@ import {
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
vi.mock('sonner', () => ({
toast: {
@ -3257,7 +3258,14 @@ describe('worktree remote runtime mutations', () => {
})
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
{
id: 'repo1',
path: '/repo1',
displayName: 'Repo 1',
badgeColor: '#000',
addedAt: 0,
executionHostId: LOCAL_EXECUTION_HOST_ID
}
],
worktreesByRepo: { repo1: [wt] }
} as Partial<AppState>)
@ -4146,8 +4154,42 @@ describe('worktree remote runtime mutations', () => {
linkedPR: 456
})
const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null)
const cacheKey = getHostedReviewCacheKey('/repo1', 'pr-branch', undefined, 'repo1')
const prCacheKey = getGitHubPRCacheKey('/repo1', 'repo1', 'pr-branch')
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => ({
id: `test-${method}`,
ok: true,
result: method === 'worktrees.list' ? [] : null
}))
const focusedRuntimeSettings = { activeRuntimeEnvironmentId: 'env-win' } as AppState['settings']
const cacheKey = getHostedReviewCacheKey(
'/repo1',
'pr-branch',
focusedRuntimeSettings,
'repo1',
null,
null,
true
)
const runtimeCacheKey = getHostedReviewCacheKey(
'/repo1',
'pr-branch',
focusedRuntimeSettings,
'repo1'
)
const prCacheKey = getGitHubPRCacheKey(
'/repo1',
'repo1',
'pr-branch',
focusedRuntimeSettings,
null,
null,
true
)
const runtimePRCacheKey = getGitHubPRCacheKey(
'/repo1',
'repo1',
'pr-branch',
focusedRuntimeSettings
)
const legacyRepoPRCacheKey = getLegacyGitHubPRCacheKey('/repo1', 'repo1', 'pr-branch')
const legacyPathPRCacheKey = getLegacyGitHubPRCacheKey('/repo1', undefined, 'pr-branch')
const prData = {
@ -4160,6 +4202,7 @@ describe('worktree remote runtime mutations', () => {
mergeable: 'MERGEABLE' as const
}
store.setState({
settings: focusedRuntimeSettings,
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
@ -4177,6 +4220,10 @@ describe('worktree remote runtime mutations', () => {
mergeable: 'MERGEABLE'
},
fetchedAt: Date.now()
},
[runtimeCacheKey]: {
data: null,
fetchedAt: Date.now()
}
},
prCache: {
@ -4184,6 +4231,10 @@ describe('worktree remote runtime mutations', () => {
data: prData,
fetchedAt: Date.now()
},
[runtimePRCacheKey]: {
data: { ...prData, title: 'Focused runtime PR' },
fetchedAt: Date.now()
},
[legacyRepoPRCacheKey]: {
data: { ...prData, title: 'Legacy repo-scoped PR' },
fetchedAt: Date.now()
@ -4200,7 +4251,9 @@ describe('worktree remote runtime mutations', () => {
expect(store.getState().worktreesByRepo.repo1[0]?.linkedPR).toBeNull()
expect(store.getState().hostedReviewCache[cacheKey]).toBeUndefined()
expect(store.getState().hostedReviewCache[runtimeCacheKey]).toBeDefined()
expect(store.getState().prCache[prCacheKey]).toBeUndefined()
expect(store.getState().prCache[runtimePRCacheKey]).toBeDefined()
expect(store.getState().prCache[legacyRepoPRCacheKey]).toBeUndefined()
expect(store.getState().prCache[legacyPathPRCacheKey]).toBeUndefined()
expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'pr-branch', {

View File

@ -2955,7 +2955,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
s.settings,
reviewRepo.id,
reviewRepo.connectionId,
reviewRepo.executionHostId
reviewRepo.executionHostId,
true
)
: null
const prCacheKey =
@ -2966,7 +2967,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
reviewBranch,
s.settings,
reviewRepo.connectionId,
reviewRepo.executionHostId
reviewRepo.executionHostId,
true
)
: null
const prCacheKeys =