From c24d916f3118e4296cfbf2fd268af17664f8add3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:57:45 -0700 Subject: [PATCH] Route hosted review operations to the owning host (#36003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Route hosted review refreshes to their owning host * Route remote review operations to their owning host - Refresh the correct hosted review after remote git operations - Scope comment mutations and AI acknowledgements to the owner cache * Route legacy paired SSH reviews through their server owner - Recover proxied runtime owners for older SSH worktree listings - Preserve direct and ambiguous host routing safeguards * Preserve review ownership across remote workspace operations - Route checks, comments, and linked-review clears through the selected repository owner - Reject ambiguous or mismatched host context to prevent cross-machine mutations * Keep check detail tabs scoped to their remote owner * Preserve remote worktree ownership across reconstructed check tabs * Preserve direct SSH check tabs when proxy listings overlap * Scope PR refresh ownership state and safely roll back comment mutations - Preserve linked-review clear scopes beyond cache eviction and clean them up on expiry - Handle failed or refused comment reactions and thread resolutions without stale optimistic UI * Preserve remote review ownership when SSH copies overlap * Fix review routing for overlapping remote repository hosts - Preserve server-owned review refreshes when a direct SSH copy exists - Fail comment mutations and refetches safely when ownership becomes invalid - Resolve check and sidebar data through the workspace’s owning host * Relax cache-sensitive test expectations * Route check refreshes to runtime owners and isolate restored requests * Scope reconstructed check detail tabs by execution owner * Fix review refresh ownership for shared remote hosts - Prefer explicit execution owners when matching worktrees. - Avoid repeated tab scans when restoring check details. * Fix hosted review polling for workspaces on multiple hosts * test: trim redundant review ownership scenarios --- config/focus-setting-read-baseline.txt | 4 +- .../ipc/github-repo-routing-twin-host.test.ts | 24 + src/main/ipc/github-repo-routing.ts | 18 +- src/main/ipc/hosted-review.ts | 7 +- src/preload/api/github-pull-request-api.ts | 5 + src/preload/api/github-work-item-api.ts | 1 + .../use-visible-review-refresh-reporting.ts | 81 +-- .../visible-review-reporting.test.ts | 26 + .../visible-review-twin-hosts.test.ts | 285 +++++++++ ...CheckRunDetailsPanel.log-outcomes.test.tsx | 2 +- .../RichMarkdownEditor.focus-leave.test.tsx | 3 +- .../editor/check-run-details-tab.ts | 9 +- .../hosted-review-manual-refresh.ts | 2 + .../saved-action-delivery.test.tsx | 20 +- .../use-checks-panel-ai-acknowledgement.tsx | 2 + .../use-checks-panel-ai-queue.tsx | 18 +- ...ks-panel-check-and-review-actions.test.tsx | 2 +- ...-checks-panel-check-and-review-actions.tsx | 7 +- .../use-checks-panel-comment-actions.test.tsx | 330 ++++++++++ .../use-checks-panel-comment-mutations.tsx | 59 +- .../use-checks-panel-comment-resolution.tsx | 62 +- .../use-checks-panel-conflict-refresh.tsx | 2 + .../use-checks-panel-controller-state.tsx | 4 +- ...-panel-entry-refresh-and-title-actions.tsx | 5 + .../use-checks-panel-git-status-effects.tsx | 2 + .../use-checks-panel-manual-refresh.tsx | 11 +- .../use-checks-panel-polling.test.tsx | 20 +- .../checks-panel/use-checks-panel-polling.tsx | 12 +- .../use-checks-panel-review-data.tsx | 17 +- .../pr-comment-snapshotted-thread-resolver.ts | 2 + .../pr-comments-ai-launch-ack.ts | 1 + .../src/lib/repository-owner-index.ts | 107 ++++ src/renderer/src/lib/review-owner.test.ts | 118 ++++ src/renderer/src/lib/review-owner.ts | 61 ++ .../worktree-operation-route-index.test.ts | 59 ++ .../src/lib/worktree-operation-route.ts | 30 +- src/renderer/src/lib/worktree-owner-route.ts | 27 +- .../src/lib/worktree-runtime-owner-index.ts | 37 +- .../src/runtime/forge-credential-target.ts | 10 +- src/renderer/src/store/github/cache-model.ts | 5 +- .../src/store/github/check-actions.ts | 21 +- .../src/store/github/comment-fetch-actions.ts | 10 +- .../store/github/comment-mutation-actions.ts | 280 +++++---- .../github/conversation-comment-actions.ts | 102 +-- .../src/store/github/linked-review-clear.ts | 108 ++++ .../github/pr-for-branch-ipc-fallback.ts | 5 +- .../src/store/github/pr-refresh-state.ts | 2 +- .../src/store/github/pr-result-cache.ts | 45 +- .../src/store/github/pull-request-actions.ts | 40 +- .../store/github/pull-request-execution.ts | 101 ++- .../src/store/github/refresh-event-actions.ts | 105 ++-- .../store/github/refresh-routing-actions.ts | 43 +- .../src/store/github/refresh-sweep-actions.ts | 39 +- .../src/store/github/repository-routing.ts | 24 +- .../src/store/github/request-coordination.ts | 8 +- .../src/store/github/review-thread-actions.ts | 188 +++--- src/renderer/src/store/github/slice-types.ts | 5 +- ...visible-hosted-review-refresh-ownership.ts | 9 +- ...ible-hosted-review-refresh-targets.test.ts | 337 +++++++++- .../visible-hosted-review-refresh-targets.ts | 74 ++- .../src/store/github/worktree-refresh.ts | 123 +++- src/renderer/src/store/selectors.test.ts | 41 ++ src/renderer/src/store/selectors.ts | 18 + ...-check-details-reconstructed-owner.test.ts | 257 ++++++++ .../editor-check-details-tab-owner.test.ts | 319 ++++++++++ .../slices/editor-check-details-tabs.test.ts | 430 ++++++++++--- .../actions/check-run-details-actions.ts | 202 +++--- .../editor/actions/check-run-details-owner.ts | 238 +++++++ .../editor/actions/git-operation-target.ts | 11 + .../editor/actions/git-remote-push-pull.ts | 17 +- .../slices/editor/actions/git-remote-sync.ts | 12 +- .../editor/tabs/workspace-editor-item.ts | 2 +- ...thub-linked-review-clear-ownership.test.ts | 505 +++++++++++++++ .../github-post-operation-owner.test.ts | 249 ++++++++ ...ithub-pr-branch-coordinator-events.test.ts | 25 +- ...hub-pr-branch-linked-pr-divergence.test.ts | 7 +- .../store/slices/github-pr-host-scope.test.ts | 580 ++++++++++++++++++ ...b-pr-refresh-linked-scope-lifetime.test.ts | 205 +++++++ .../github-pr-refresh-owner-routing.test.ts | 51 ++ src/renderer/src/store/slices/github.ts | 2 + .../store/slices/hosted-review-cache-state.ts | 9 +- .../src/store/slices/hosted-review.ts | 19 +- .../worktrees-metadata-persistence.test.ts | 1 + .../metadata/update-worktree-meta.ts | 21 +- src/shared/check-run-details-owner-scope.ts | 16 + src/shared/hosted-review.ts | 1 + ...orkspace-layout-load-check-details.test.ts | 102 +++ .../workspace-layout-load-workspace.ts | 27 + tests/e2e/claude-agent-row-view.unit.test.ts | 10 +- 89 files changed, 5669 insertions(+), 874 deletions(-) create mode 100644 src/renderer/src/app-shell/visible-review-twin-hosts.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-actions.test.tsx create mode 100644 src/renderer/src/lib/repository-owner-index.ts create mode 100644 src/renderer/src/lib/review-owner.test.ts create mode 100644 src/renderer/src/lib/review-owner.ts create mode 100644 src/renderer/src/lib/worktree-operation-route-index.test.ts create mode 100644 src/renderer/src/store/github/linked-review-clear.ts create mode 100644 src/renderer/src/store/slices/editor-check-details-reconstructed-owner.test.ts create mode 100644 src/renderer/src/store/slices/editor-check-details-tab-owner.test.ts create mode 100644 src/renderer/src/store/slices/editor/actions/check-run-details-owner.ts create mode 100644 src/renderer/src/store/slices/github-linked-review-clear-ownership.test.ts create mode 100644 src/renderer/src/store/slices/github-post-operation-owner.test.ts create mode 100644 src/renderer/src/store/slices/github-pr-host-scope.test.ts create mode 100644 src/renderer/src/store/slices/github-pr-refresh-linked-scope-lifetime.test.ts create mode 100644 src/shared/check-run-details-owner-scope.ts create mode 100644 src/shared/workspace-layout/workspace-layout-load-check-details.test.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 556449ebe88..374896977a2 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -261,7 +261,7 @@ 2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts 1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts 3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts -2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +1 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 9 src/renderer/src/store/index.ts 2 src/renderer/src/store/project-groups/project-group-mutations.ts 1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -324,7 +324,7 @@ 2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts 2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts 1 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts -2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +1 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts diff --git a/src/main/ipc/github-repo-routing-twin-host.test.ts b/src/main/ipc/github-repo-routing-twin-host.test.ts index 9ffc7b50caa..89b912188a6 100644 --- a/src/main/ipc/github-repo-routing-twin-host.test.ts +++ b/src/main/ipc/github-repo-routing-twin-host.test.ts @@ -86,6 +86,30 @@ describe('registered GitHub repo lookup for a repo id on two hosts', () => { ).toBe(samePathSshTwin) }) + it('keeps checks and comments on the requested host when both copies share a path', () => { + const store = storeWith([localTwin, samePathSshTwin]) + expect( + assertRegisteredGitHubRepo( + { + repoPath: localTwin.path, + repoId: localTwin.id, + repoOwnerExecutionHostId: 'ssh:builder' + }, + store + ) + ).toBe(samePathSshTwin) + expect(() => + assertRegisteredGitHubRepo( + { + repoPath: localTwin.path, + repoId: localTwin.id, + repoOwnerExecutionHostId: 'ssh:missing' + }, + store + ) + ).toThrow('host does not match repository host') + }) + it("keeps a lone row's own host over a stale candidate host", () => { const result = validateAutomaticPRRefreshCandidate( candidate({ repoPath: localTwin.path, connectionId: 'gone' }), diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts index 0cd8b54cf66..bd004b9410a 100644 --- a/src/main/ipc/github-repo-routing.ts +++ b/src/main/ipc/github-repo-routing.ts @@ -20,6 +20,7 @@ import { guardWslUncPathAccess, WslDistroStoppedError } from '../wsl/wsl-unc-acc export type GitHubRepoScopedArgs = { repoPath: string repoId?: string | null + repoOwnerExecutionHostId?: string sourceContext?: TaskSourceContext | null } @@ -30,6 +31,7 @@ type RegisteredRepoValidationResult = type RegisteredRepoQuery = { repoPath: string repoId?: string | null + repoOwnerExecutionHostId?: string /** Picks between twins (one id on two hosts); a lone row's own fields still win (they may be stale). */ hostHint?: Parameters[0] | null sourceContext?: TaskSourceContext | null @@ -66,9 +68,14 @@ function validateRegisteredRepo( message: 'Access denied: unknown repository path' } } - if (sourceContext?.provider === 'github') { + const ownerHostId = + query.repoOwnerExecutionHostId ?? + (sourceContext?.provider === 'github' ? sourceContext.hostId : null) + if (ownerHostId) { const repo = pathRows.find( - (candidate) => getRepoExecutionHostId(candidate) === sourceContext.hostId + (candidate) => + getRepoExecutionHostId(candidate) === ownerHostId && + (sourceContext?.provider !== 'github' || sourceContext.hostId === ownerHostId) ) return repo ? { kind: 'ok', repo } @@ -89,7 +96,12 @@ function validateRegisteredRepo( function registeredRepoQuery(args: string | GitHubRepoScopedArgs): RegisteredRepoQuery { return typeof args === 'string' ? { repoPath: args } - : { repoPath: args.repoPath, repoId: args.repoId, sourceContext: args.sourceContext } + : { + repoPath: args.repoPath, + repoId: args.repoId, + repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, + sourceContext: args.sourceContext + } } function prRefreshCandidateQuery(candidate: GitHubPRRefreshCandidate): RegisteredRepoQuery { diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 284b3d197b4..7e4bc2eac00 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -43,7 +43,10 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } -function assertRegisteredRepoForBranch(args: HostedReviewForBranchArgs, store: Store): Repo { +function assertRegisteredRepoForBranch( + args: Pick, + store: Store +): Repo { if (!args.repoOwnerExecutionHostId) { return assertRegisteredRepo(args.repoPath, store, args.repoId) } @@ -152,7 +155,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ipcMain.handle( 'hostedReview:getCreationEligibility', async (_event, args: HostedReviewCreationEligibilityArgs) => { - const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const repo = assertRegisteredRepoForBranch(args, store) const localGitOptions = getLocalProjectGhExecOptions(store, repo) // Source Control asks on mount; only the user's Create PR may start a stopped distro (E9). return guardWslUncPathAccess( diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts index 2aaa502942c..566dd2ccc66 100644 --- a/src/preload/api/github-pull-request-api.ts +++ b/src/preload/api/github-pull-request-api.ts @@ -38,6 +38,7 @@ export type GithubPullRequestApi = { prForBranch: (args: { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null @@ -86,6 +87,7 @@ export type GithubPullRequestApi = { prCheckDetails: (args: { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number @@ -104,6 +106,7 @@ export type GithubPullRequestApi = { prComments: (args: { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -112,6 +115,7 @@ export type GithubPullRequestApi = { setPRCommentReaction: (args: { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string sourceContext?: TaskSourceContext | null reactionSubjectId: string content: GitHubReactionContent @@ -121,6 +125,7 @@ export type GithubPullRequestApi = { resolveReviewThread: (args: { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string sourceContext?: TaskSourceContext | null threadId: string resolve: boolean diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts index 83c5fb268f7..13424e74186 100644 --- a/src/preload/api/github-work-item-api.ts +++ b/src/preload/api/github-work-item-api.ts @@ -43,6 +43,7 @@ import type { import type { GitHubCreateIssueResult, GitHubIssueUpdate } from '../../shared/issue-mutation-types' export type GitHubRepoSelectorArgs = { + repoOwnerExecutionHostId?: string repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null diff --git a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts index 91c3cb05ba7..579860b61b7 100644 --- a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts +++ b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts @@ -4,33 +4,30 @@ import type { AppState } from '../store/types' import { rightSidebarShowsPullRequestData } from '../lib/right-sidebar-visibility' import { findWorktreeById, buildPRRefreshCandidate } from '../store/github/worktree-refresh' import { getHostedReviewCacheKey } from '../store/slices/hosted-review-cache-identity' -import { getIndexedRepoMap } from '../store/worktree-repo-index' +import { getVisibleHostedReviewWorkspaces } from '../store/github/visible-hosted-review-refresh-targets' import { shouldCoordinateVisibleGitHubReview } from '../store/github/visible-hosted-review-refresh-ownership' import { getPRRefreshRuntimeRepoTarget } from '../store/github/repository-routing' import { reviewRefreshIntervalMs, REVIEW_REFRESH_COOLDOWN_MS } from '../../../shared/review-refresh-policy' -import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' -export function visibleReviewWorktreeIdsForState(state: AppState): string[] { +function visibleReviewWorkspacesForState(state: AppState) { const ids = new Set(state.visibleReviewCardWorktreeIds ?? []) - const repos = getIndexedRepoMap(state.repos) if (state.activeWorktreeId && rightSidebarShowsPullRequestData(state)) { ids.add(state.activeWorktreeId) } - return Array.from(ids).filter((id) => { - const worktree = findWorktreeById(state, id) - const repo = worktree && repos.get(worktree.repoId) - return ( - worktree && - repo && - (repo.kind ?? 'git') === 'git' && - !worktree.isBare && - !worktree.isArchived && - Boolean(worktree.branch) - ) - }) + return [...getVisibleHostedReviewWorkspaces(state, { worktreeIds: [...ids] })].filter( + ({ worktree }) => Boolean(worktree.branch) + ) +} + +export function visibleReviewWorktreeIdsForState(state: AppState): string[] { + return [...new Set(visibleReviewWorkspacesForState(state).map(({ worktree }) => worktree.id))] } function reportIdentity(state: AppState): string { @@ -39,25 +36,19 @@ function reportIdentity(state: AppState): string { rightSidebarShowsPullRequestData(state), state.sshConnectedGeneration, state.prVisibleRefreshGeneration, - visibleReviewWorktreeIdsForState(state).map((id) => { - const worktree = findWorktreeById(state, id) - if (!worktree) { - return id - } - const candidate = buildPRRefreshCandidate(state, worktree) - const key = - candidate && - getHostedReviewCacheKey( - candidate.repoPath, - candidate.branch, - state.settings, - candidate.repoId, - candidate.connectionId, - candidate.executionHostId, - true - ) + visibleReviewWorkspacesForState(state).map(({ worktree, repo, candidate, selected }) => { + const key = getHostedReviewCacheKey( + candidate.repoPath, + candidate.branch, + state.settings, + candidate.repoId, + candidate.connectionId, + candidate.executionHostId, + true + ) return [ - id, + worktree.id, + selected, worktree.branch, worktree.head, worktree.linkedPR, @@ -65,12 +56,12 @@ function reportIdentity(state: AppState): string { worktree.linkedBitbucketPR, worktree.linkedAzureDevOpsPR, worktree.linkedGiteaPR, - candidate?.connectionState, - candidate?.executionHostId, - candidate?.cacheKey, - getIndexedRepoMap(state.repos).get(worktree.repoId)?.gitRemoteIdentity?.canonicalKey, - Boolean(candidate && state.prCache[candidate.cacheKey]?.data), - key ? state.hostedReviewCache[key]?.data?.provider : null + candidate.connectionState, + candidate.executionHostId, + candidate.cacheKey, + repo.gitRemoteIdentity?.canonicalKey, + Boolean(state.prCache[candidate.cacheKey]?.data), + state.hostedReviewCache[key]?.data?.provider ] }) ]) @@ -79,11 +70,16 @@ function reportIdentity(state: AppState): string { const REPORT_INPUT_KEYS = [ 'activeView', 'activeWorktreeId', + 'activeWorkspaceExecutionHostId', 'rightSidebarOpen', 'rightSidebarTab', 'visibleReviewCardWorktreeIds', + 'visibleWorkspaceHostIds', + 'workspaceHostScope', 'repos', 'worktreesByRepo', + 'detectedWorktreesByRepo', + 'wslHostStatuses', 'settings', 'sshConnectionStates', 'sshConnectedGeneration', @@ -106,11 +102,16 @@ export function createVisibleReviewReportIdentitySelector(): (state: AppState) = previous = { activeView: state.activeView, activeWorktreeId: state.activeWorktreeId, + activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, rightSidebarOpen: state.rightSidebarOpen, rightSidebarTab: state.rightSidebarTab, visibleReviewCardWorktreeIds: state.visibleReviewCardWorktreeIds, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + workspaceHostScope: state.workspaceHostScope, repos: state.repos, worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), + wslHostStatuses: state.wslHostStatuses, settings: state.settings, sshConnectionStates: state.sshConnectionStates, sshConnectedGeneration: state.sshConnectedGeneration, diff --git a/src/renderer/src/app-shell/visible-review-reporting.test.ts b/src/renderer/src/app-shell/visible-review-reporting.test.ts index d69a57f84de..eea0e3a3d14 100644 --- a/src/renderer/src/app-shell/visible-review-reporting.test.ts +++ b/src/renderer/src/app-shell/visible-review-reporting.test.ts @@ -63,6 +63,32 @@ describe('review surface visibility union', () => { }) describe('review report selector work', () => { + it.each(['ssh:builder', 'runtime:server'] as const)( + 'updates the report when switching between same-ID local and %s worktrees', + (hostId) => { + const store = state() + const localRepo = store.getState().repos[0] + const localWorktree = makePRRefreshWorktree({ id: 'selected', hostId: 'local' }) + store.setState({ + repos: [localRepo, { ...localRepo, executionHostId: hostId }], + worktreesByRepo: { + 'repo-1': [localWorktree, { ...localWorktree, hostId }] + }, + activeWorkspaceExecutionHostId: 'local' + }) + const select = createVisibleReviewReportIdentitySelector() + const localReport = select(store.getState()) + + store.setState({ activeWorkspaceExecutionHostId: hostId }) + const remoteReport = select(store.getState()) + expect(remoteReport).not.toBe(localReport) + expect(remoteReport).toBe(createVisibleReviewReportIdentitySelector()(store.getState())) + + store.setState({ activeWorkspaceExecutionHostId: 'local' }) + expect(select(store.getState())).toBe(localReport) + } + ) + it('does not rebuild candidates or serialize on unrelated agent updates', () => { const current = state().getState() const select = createVisibleReviewReportIdentitySelector() diff --git a/src/renderer/src/app-shell/visible-review-twin-hosts.test.ts b/src/renderer/src/app-shell/visible-review-twin-hosts.test.ts new file mode 100644 index 00000000000..41a1cf1f59c --- /dev/null +++ b/src/renderer/src/app-shell/visible-review-twin-hosts.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { GitHubPRRefreshCandidate } from '../../../shared/github/pull-request-refresh-types' +import type { HostedReviewInfo } from '../../../shared/hosted-review' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' +import { + createTestStore, + makePR, + makePRRefreshWorktree, + mockApi, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from '../store/slices/github-slice-test-harness' +import { getVisibleHostedReviewRefreshTargets } from '../store/github/visible-hosted-review-refresh-targets' +import { + buildPRRefreshCandidate, + findWorktreeById, + isStaleExactLinkedPRLookup +} from '../store/github/worktree-refresh' +import { + createVisibleReviewReportIdentitySelector, + visibleReviewWorktreeIdsForState +} from './use-visible-review-refresh-reporting' + +const reportVisible = + vi.fn< + (args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => Promise + >() +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/team/repo', + remoteName: 'origin', + remoteUrl: 'https://github.com/team/repo.git' + } +} +const card = makePRRefreshWorktree({ id: 'card', hostId: 'local', linkedPR: 11 }) + +function twins(remoteHost: ExecutionHostId, provider = 'github') { + const store = createTestStore() + const owner = { + ...repo, + gitRemoteIdentity: { + canonicalKey: `${provider}.com/team/repo`, + remoteName: 'origin', + remoteUrl: `https://${provider}.com/team/repo.git` + } + } + store.setState({ + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'elsewhere' }), + repos: [ + owner, + { + ...owner, + executionHostId: remoteHost, + ...(remoteHost === 'ssh:builder' ? { connectionId: 'builder' } : {}) + } + ], + worktreesByRepo: { + 'repo-1': [card, { ...card, hostId: remoteHost, head: 'remote-head', linkedPR: 22 }] + }, + detectedWorktreesByRepo: {}, + activeWorktreeId: 'another-workspace', + activeWorkspaceExecutionHostId: 'local', + rightSidebarOpen: false, + visibleReviewCardWorktreeIds: [card.id], + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all', + sshConnectionStates: new Map([ + ['builder', { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + }) + return store +} + +beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + reportVisible.mockResolvedValue(true) + vi.stubGlobal('window', { + api: { ...mockApi, gh: { ...mockApi.gh, reportVisiblePRRefreshCandidates: reportVisible } } + }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('visible unselected workspace twins', () => { + it('refreshes a server-selected legacy SSH row when its direct copy also exists', async () => { + const store = twins('runtime:server') + const server = { ...repo, executionHostId: 'runtime:server' as const, connectionId: 'builder' } + const direct = { ...server, executionHostId: 'ssh:builder' as const } + store.setState({ + repos: [direct, server], + worktreesByRepo: { [repo.id]: [{ ...card, hostId: 'ssh:builder', linkedPR: 22 }] }, + activeRepoId: repo.id, + activeWorktreeId: card.id, + activeWorkspaceExecutionHostId: 'runtime:server', + visibleWorkspaceHostIds: ['runtime:server'] + }) + const ids = visibleReviewWorktreeIdsForState(store.getState()) + expect(ids).toEqual([card.id]) + await store.getState().reportVisibleGitHubPRRefreshCandidates(ids, 123) + expect(reportVisible.mock.calls[0][0].candidates).toEqual([]) + const targets = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState) + expect(targets.map((target) => target.key)).toEqual([`runtime:server::repo-1::${card.branch}`]) + runtimeEnvironmentCall.mockResolvedValue({ id: 'pr', ok: true, result: makePR({ number: 22 }) }) + expect(await targets[0].refresh()).toBe(true) + expect(store.getState().prCache[targets[0].key]?.data?.number).toBe(22) + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + }) + + it.each([ + ['ssh:builder', 'all'], + ['runtime:server', 'all'], + ['runtime:server', 'local'], + ['runtime:server', 'remote'] + ] as const)('reports GitHub owners on %s with the %s host filter', async (hostId, filter) => { + const store = twins(hostId) + const hosts: ExecutionHostId[] = + filter === 'all' ? ['local', hostId] : [filter === 'local' ? 'local' : hostId] + store.setState({ visibleWorkspaceHostIds: hosts }) + + const ids = visibleReviewWorktreeIdsForState(store.getState()) + expect(ids).toEqual([card.id]) + await store.getState().reportVisibleGitHubPRRefreshCandidates(ids, 123) + + const localHosts = hosts.filter((host) => !host.startsWith('runtime:')) + expect(reportVisible).toHaveBeenCalledExactlyOnceWith({ + generation: 123, + candidates: localHosts.map((host) => + expect.objectContaining({ + worktreeId: card.id, + cacheKey: `${host === 'local' ? '' : `${host}::`}repo-1::${card.branch}`, + linkedPRNumber: host === 'local' ? 11 : 22, + isSelected: false + }) + ) + }) + const targets = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState) + expect(targets.map((target) => target.key)).toEqual( + hosts + .filter((host) => host.startsWith('runtime:')) + .map((host) => `${host}::repo-1::${card.branch}`) + ) + expect(targets.every((target) => !target.selected)).toBe(true) + runtimeEnvironmentCall.mockResolvedValue({ id: 'pr', ok: true, result: makePR({ number: 22 }) }) + for (const target of targets) { + expect(await target.refresh()).toBe(true) + expect(store.getState().prCache[target.key]?.data?.number).toBe(22) + } + }) + + it('refreshes provider-neutral twins and tracks hidden-owner and visible-owner changes', async () => { + const store = twins('ssh:builder', 'gitlab') + store.setState({ + worktreesByRepo: { + 'repo-1': store.getState().worktreesByRepo['repo-1'].map((worktree) => ({ + ...worktree, + linkedPR: null, + linkedGitLabMR: worktree.hostId === 'local' ? 11 : 22 + })) + } + }) + const select = createVisibleReviewReportIdentitySelector() + const all = select(store.getState()) + await store + .getState() + .reportVisibleGitHubPRRefreshCandidates(visibleReviewWorktreeIdsForState(store.getState()), 1) + const targets = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState) + expect(targets.map((target) => target.key)).toEqual([ + `local::repo-1::${card.branch}`, + `ssh:builder::repo-1::${card.branch}` + ]) + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 11, + title: 'Review', + url: 'https://gitlab.com/team/repo/-/merge_requests/11', + state: 'open', + status: 'success', + updatedAt: '', + mergeable: 'MERGEABLE' + } + mockApi.hostedReview.forBranch + .mockResolvedValueOnce(review) + .mockResolvedValueOnce({ ...review, number: 22 }) + for (const target of targets) { + expect(await target.refresh()).toBe(true) + expect(store.getState().hostedReviewCache[target.key]?.data).toMatchObject({ + provider: 'gitlab', + number: target.key.startsWith('local::') ? 11 : 22 + }) + } + const beforeFilter = select(store.getState()) + store.setState({ workspaceHostScope: 'local' }) + const local = select(store.getState()) + expect(local).not.toBe(all) + expect(local).not.toBe(beforeFilter) + const [localCard, remoteCard] = store.getState().worktreesByRepo['repo-1'] + store.setState({ worktreesByRepo: { 'repo-1': [localCard, { ...remoteCard, head: 'new' }] } }) + expect(select(store.getState())).toBe(local) + store.setState({ workspaceHostScope: 'ssh:builder' }) + const remote = select(store.getState()) + expect(remote).not.toBe(local) + store.setState({ worktreesByRepo: { 'repo-1': [localCard, { ...remoteCard, head: 'newer' }] } }) + expect(select(store.getState())).not.toBe(remote) + const ids = visibleReviewWorktreeIdsForState(store.getState()) + expect(ids).toEqual([card.id]) + await store.getState().reportVisibleGitHubPRRefreshCandidates(ids, 2) + expect( + getVisibleHostedReviewRefreshTargets(store.getState(), store.getState).map((t) => t.key) + ).toEqual([`ssh:builder::repo-1::${card.branch}`]) + }) + + it('marks only the selected host when both twins are visible', async () => { + const store = twins('ssh:builder') + store.setState({ activeWorktreeId: card.id, activeWorkspaceExecutionHostId: 'ssh:builder' }) + await store + .getState() + .reportVisibleGitHubPRRefreshCandidates(visibleReviewWorktreeIdsForState(store.getState()), 1) + expect( + reportVisible.mock.calls[0][0].candidates.map((candidate) => candidate.isSelected) + ).toEqual([false, true]) + }) + + it('discovers each host using its own remote provider identity', async () => { + const store = twins('runtime:server', 'gitlab') + const [local, remote] = store.getState().repos + store.setState({ + repos: [local, { ...remote, gitRemoteIdentity: repo.gitRemoteIdentity }], + worktreesByRepo: { + 'repo-1': store.getState().worktreesByRepo['repo-1'].map((worktree) => ({ + ...worktree, + linkedPR: null + })) + } + }) + await store + .getState() + .reportVisibleGitHubPRRefreshCandidates(visibleReviewWorktreeIdsForState(store.getState()), 1) + expect(reportVisible.mock.calls[0][0].candidates).toEqual([]) + const targets = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState) + expect(targets).toHaveLength(2) + runtimeEnvironmentCall.mockResolvedValue({ id: 'pr', ok: true, result: makePR({ number: 22 }) }) + expect(await targets.find((target) => target.key.startsWith('runtime:'))?.refresh()).toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'github.prForBranch', + selector: 'server' + }) + ) + }) +}) + +it('retains unstamped local linked PR results when the repository has multiple hosts', async () => { + const store = twins('runtime:server') + const unstamped = { ...card, hostId: undefined } + store.setState({ worktreesByRepo: { 'repo-1': [unstamped] } }) + expect(findWorktreeById(store.getState(), card.id, 'local')).toBe(unstamped) + expect(findWorktreeById(store.getState(), card.id, 'runtime:server')).toBeNull() + expect(isStaleExactLinkedPRLookup(store.getState(), card.id, 11, 'local')).toBe(false) + expect(buildPRRefreshCandidate(store.getState(), unstamped)).toMatchObject({ + repoPath: repo.path, + cacheKey: `repo-1::${card.branch}`, + linkedPRNumber: 11 + }) + mockApi.gh.refreshPRNow.mockResolvedValue({ + kind: 'found', + pr: makePR({ number: 11 }), + fetchedAt: 123 + }) + const result = await store.getState().fetchPRForBranch(repo.path, card.branch, { + repoId: repo.id, + repoOwnerExecutionHostId: 'local', + worktreeId: card.id, + linkedPRNumber: 11 + }) + expect(result?.number).toBe(11) + expect(store.getState().prCache[`repo-1::${card.branch}`]?.data?.number).toBe(11) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.log-outcomes.test.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.log-outcomes.test.tsx index 779556ad4a3..69946815d30 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.log-outcomes.test.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.log-outcomes.test.tsx @@ -292,7 +292,7 @@ describe('synthetic outcome payloads through real sidebar and header actions', ( expect(fetch).toHaveBeenCalledWith( '/repo', { checkRunId: 42, workflowRunId: 55, checkName: 'verify', url: check.url, prRepo: null }, - { repoId: 'repo-1' } + { repoId: 'repo-1', repoOwnerExecutionHostId: 'local' } ) const during = useAppStore.getState().openFiles[0].checkRunDetails const positions = [...scrollTopCache] diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.focus-leave.test.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.focus-leave.test.tsx index c0fd6bbd5c9..e1f98d594fc 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.focus-leave.test.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.focus-leave.test.tsx @@ -61,7 +61,8 @@ it.each(['outside', 'null', 'inside'] as const)( headerSlot={} /> ) - await act(async () => {}) + // Why: Tiptap emits create on a zero-delay timer, and typing before it lands is dropped as initialization. + await act(() => vi.waitFor(() => expect(live.editor?.isInitialized).toBe(true))) const editor = live.editor const root = view.container.querySelector('.rich-markdown-editor-shell') if (!editor || !root) { diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts index 59efd8a7795..dca72a6f9a9 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -46,9 +46,14 @@ export function getCheckRunTabIdentity(check: PRCheckDetail): string { return `name:${check.name}` } -export function buildCheckRunDetailsTabId(worktreeId: string, check: PRCheckDetail): string { +export function buildCheckRunDetailsTabId( + worktreeId: string, + check: PRCheckDetail, + ownerScope?: string | null +): string { // Keep tab identity stable across PR head refreshes; contextKey belongs to tab state. - return `${worktreeId}::check-details::${getCheckRunTabIdentity(check)}` + const scope = ownerScope ? `@${ownerScope}` : '' + return `${worktreeId}::check-details${scope}::${getCheckRunTabIdentity(check)}` } export function getCheckRunDetailsTabLabel(check: PRCheckDetail): string { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/hosted-review-manual-refresh.ts b/src/renderer/src/components/right-sidebar/checks-panel/hosted-review-manual-refresh.ts index b292d6ac397..6d601543370 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/hosted-review-manual-refresh.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/hosted-review-manual-refresh.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import type { HostedReviewProvider } from '../../../../../shared/hosted-review' import { useAppStore } from '@/store' import { latestLookupGeneration } from '@/store/lookup-generation-sequence' @@ -72,6 +73,7 @@ export function refreshHostedReviewForClick( return refreshHostedReviewCard(model.fetchHostedReviewForBranch, { repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), branch, admissionTier: 'interactive', userRefresh: true, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx index 55bca07d3c0..151d720a962 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx @@ -47,7 +47,7 @@ function deferred() { }) return { promise, resolve } } -function fixture(saved = true, paired = false) { +function fixture(saved = true, paired = false, conflictingOwner = false) { setLocalRuntimeCapabilitiesForTests([]) Object.defineProperty(window, 'api', { configurable: true, @@ -60,7 +60,14 @@ function fixture(saved = true, paired = false) { resolveConflicts: { agentId: 'codex' } } : {} - const repo = { id: 'repo-1', path: '/repo', displayName: 'Repo', badgeColor: 'blue', addedAt: 1 } + const repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + ...(paired && !conflictingOwner ? { executionHostId: 'runtime:alpha' as const } : {}) + } const worktree = makeWorktree({ id: 'repo-1::wt-1', repoId: repo.id }) if (paired) { Object.assign(worktree, { hostId: 'runtime:alpha' }) @@ -283,6 +290,15 @@ function fixture(saved = true, paired = false) { } } +it('rejects conflicting review ownership before queuing an AI acknowledgement', async () => { + const f = fixture(true, true, true) + await act(async () => {}) + act(() => f.result.current.handleResolveCommentsWithAI(f.groups)) + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('owning host')) + expect(launch).not.toHaveBeenCalled() + expect(f.pending.current).toBeNull() +}) + it('owns detection single-flight, returns failed delivery to the editor, then consumes its exact review once', async () => { const delivery = deferred<{ delivered: boolean; failureNotified: boolean }>() launch.mockReturnValue({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index 8c1e1e01d54..abef3d3a1e8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -148,6 +148,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem body, { repoId: githubTarget.repoId, + repoOwnerExecutionHostId: githubTarget.ownerHostId, prRepo: githubTarget.prRepo, threadId: parentThreadId, path: comment.path, @@ -190,6 +191,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem body, { repoId: githubTarget.repoId, + repoOwnerExecutionHostId: githubTarget.ownerHostId, prRepo: githubTarget.prRepo } ) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx index 0244878c569..6fdac2ad855 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx @@ -179,12 +179,20 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { ) return } + let ownerHostId: string + try { + ownerHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + return + } // Why: pr.prRepo can be missing while comments are still visible; fall back to the PR URL. const githubTargetFromPr = activeReview.provider === 'github' && prNumber && pr?.prRepo ? { repoPath: repo.path, repoId: repo.id, + ownerHostId, prNumber, prRepo: pr.prRepo } @@ -200,6 +208,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { return { repoPath: repo.path, repoId: repo.id, + ownerHostId, prNumber: link.number, prRepo: { owner: link.slug.owner, @@ -214,7 +223,12 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { const githubResolveTarget = githubTarget ?? (activeReview.provider === 'github' && prNumber - ? { repoPath: repo.path, repoId: repo.id, prNumber } + ? { + repoPath: repo.path, + repoId: repo.id, + prNumber, + ownerHostId + } : undefined) // Why: the ack resolves against this MR after delivery, so pin the iid now — the panel // may already be showing a different review by then. @@ -224,7 +238,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { repoPath: repo.path, repoId: repo.id, iid: activeReview.number, - ownerHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) + ownerHostId } : undefined const commentResolution = { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx index 547fd906917..950143a25e0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx @@ -319,7 +319,7 @@ describe('useChecksPanelCheckAndReviewActions AI details diagnostics', () => { url: null, prRepo: null }, - { repoId: 'repo-1' } + { repoId: 'repo-1', repoOwnerExecutionHostId: 'ssh:ssh-1' } ) expect(mocks.loadGitLabJobLogDetails).not.toHaveBeenCalled() expect(mocks.startFixChecksAgent).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index d637ce363e2..f0b2c4617ff 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import React, { useCallback } from 'react' import { toast } from 'sonner' @@ -104,7 +105,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe url: check.url, prRepo: pr?.prRepo ?? null }, - { repoId: repo.id } + { repoId: repo.id, repoOwnerExecutionHostId: getRepoExecutionHostId(repo) } ) } catch { console.warn('[ChecksPanel] failed to load check details for AI fix prompt') @@ -177,6 +178,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), worktreeId: activeWorktreeId ?? undefined, linkedPRNumber, userRefresh: true @@ -187,6 +189,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe await refreshHostedReviewCard(fetchHostedReviewForBranch, { repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), branch, linkedGitHubPR: linkedPRNumber, userRefresh: true, @@ -223,6 +226,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), wakeWslDistro: true, userRefresh: true } @@ -249,6 +253,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe fetchPRComments(repo.path, refreshedPR.number, { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), prRepo: refreshedPR.prRepo, userRefresh: true }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-actions.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-actions.test.tsx new file mode 100644 index 00000000000..8dc678a2453 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-actions.test.tsx @@ -0,0 +1,330 @@ +// @vitest-environment happy-dom + +import { useState, type SetStateAction } from 'react' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitLabDiscussionResolveResult } from '../../../../../shared/gitlab-types' +import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key' +import { useChecksPanelCommentMutations } from './use-checks-panel-comment-mutations' +import { useChecksPanelCommentResolution } from './use-checks-panel-comment-resolution' + +const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +type Input = Omit< + Parameters[0] & + Parameters[0], + 'setComments' +> + +const comments: PRComment[] = [ + { + id: 1, + author: 'reviewer', + authorAvatarUrl: '', + body: 'Review comment', + createdAt: '2026-10-01T00:00:00Z', + url: '', + reactionSubjectId: 'comment-node', + reactions: [{ content: '+1', count: 2, viewerHasReacted: false }], + threadId: 'thread-1', + isResolved: false + }, + { + id: 2, + author: 'author', + authorAvatarUrl: '', + body: 'Thread reply', + createdAt: '2026-10-01T00:00:00Z', + url: '', + threadId: 'thread-1', + isResolved: false + } +] + +function makeInput(overrides: Partial = {}): Input { + const pr = { + number: 42, + title: 'Review', + state: 'open' as const, + url: 'https://github.com/acme/repo/pull/42', + checksStatus: 'success' as const, + updatedAt: '2026-10-01T00:00:00Z', + mergeable: 'MERGEABLE' as const, + headSha: 'head-sha', + prRepo: { owner: 'acme', repo: 'repo' } + } + return { + activeWorktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:workspace-host' }), + activeWorktreeId: 'wt-1', + repo: { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'Repo', + badgeColor: '', + addedAt: 0, + connectionId: 'workspace-host' + }, + branch: 'feature', + pr, + prCacheKey: 'repo-cache', + prNumber: 42, + activeReview: null, + activeGitLabReview: null, + asyncResultKeyRef: { + current: checksPanelAsyncResultKey('repo-cache', 'feature', 42, pr.prRepo, pr.headSha) + }, + commentsRef: { current: comments }, + commentsDisabledReason: undefined, + commentsLoading: false, + confirm: vi.fn(async () => true), + isCurrentAsyncResult: vi.fn(() => true), + addPRConversationComment: vi.fn(), + addPRReviewCommentReply: vi.fn(), + setPRCommentReaction: vi.fn(async () => true), + resolveReviewThread: vi.fn(async () => true), + claimedCommentResolutionRef: { current: null }, + pendingCommentResolutionRef: { current: null }, + commentResolutionLaunchAcceptedRef: { current: false }, + commentResolutionAckBusy: false, + detectedAgentIds: null, + setAgentComposerState: vi.fn(), + settings: null, + sourceControlAiActionsVisible: false, + ...overrides + } +} + +function renderActions(input: Input) { + const updates: SetStateAction[] = [] + return { + updates, + ...renderHook(() => { + const [currentComments, setCurrentComments] = useState(comments) + const model = { + ...input, + setComments: (next: SetStateAction) => { + updates.push(next) + setCurrentComments(next) + } + } + return { + comments: currentComments, + ...useChecksPanelCommentMutations(model), + ...useChecksPanelCommentResolution(model) + } + }) + } +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('comment action ownership refusal', () => { + it('returns a failure for a new comment without posting or changing comments', async () => { + const input = makeInput({ + activeWorktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:other-host' }) + }) + const { result, updates } = renderActions(input) + + await act(async () => { + await expect(result.current.handleAddPRComment('New comment')).resolves.toEqual({ + ok: false, + error: 'The repository does not match this workspace’s owning host.' + }) + }) + + expect(result.current.comments).toEqual(comments) + expect(updates).toHaveLength(0) + expect(input.addPRConversationComment).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledWith( + 'The repository does not match this workspace’s owning host.' + ) + }) + + it.each(['review', 'conversation'] as const)( + 'returns a failure for a %s reply, honoring notification options', + async (kind) => { + const input = makeInput({ + activeWorktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:other-host' }) + }) + const { result, updates } = renderActions(input) + const parent = kind === 'review' ? comments[0] : { ...comments[0], threadId: undefined } + const failure = { + ok: false, + error: 'The repository does not match this workspace’s owning host.' + } + + await act(async () => { + await expect(result.current.handleReplyToComment(parent, 'Reply')).resolves.toEqual(failure) + }) + expect(toastError).toHaveBeenCalledWith(failure.error) + + toastError.mockClear() + await act(async () => { + await expect( + result.current.handleReplyToComment(parent, 'Reply', { notifyOnFailure: false }) + ).resolves.toEqual(failure) + }) + + expect(toastError).not.toHaveBeenCalled() + expect(input.addPRReviewCommentReply).not.toHaveBeenCalled() + expect(input.addPRConversationComment).not.toHaveBeenCalled() + expect(result.current.comments).toEqual(comments) + expect(updates).toHaveLength(0) + } + ) + + it('refuses a reaction before changing comments and reports failure', async () => { + const input = makeInput({ + activeWorktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:other-host' }) + }) + const { result, updates } = renderActions(input) + + await act(async () => { + expect(await result.current.handleSetReaction(comments[0], '+1', true)).toBe(false) + }) + + expect(result.current.comments).toEqual(comments) + expect(updates).toHaveLength(0) + expect(input.setPRCommentReaction).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledOnce() + }) + + it.each(['github', 'gitlab'] as const)( + 'refuses %s thread resolution before changing comments, honoring notification options', + async (provider) => { + const resolveMRDiscussion = vi.fn() + vi.stubGlobal('api', { gl: { resolveMRDiscussion } }) + const input = makeInput({ + activeWorktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:other-host' }), + activeGitLabReview: + provider === 'gitlab' + ? { + provider: 'gitlab', + number: 42, + title: 'MR', + state: 'open', + url: 'https://gitlab.com/acme/repo/-/merge_requests/42', + status: 'success', + updatedAt: '2026-10-01T00:00:00Z', + mergeable: 'MERGEABLE' + } + : null + }) + const { result, updates } = renderActions(input) + + await act(async () => { + expect(await result.current.handleResolve('thread-1', true)).toBe(false) + }) + + expect(result.current.comments).toEqual(comments) + expect(updates).toHaveLength(0) + expect(input.resolveReviewThread).not.toHaveBeenCalled() + expect(resolveMRDiscussion).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledOnce() + + toastError.mockClear() + await act(async () => { + expect( + await result.current.handleResolve('thread-1', true, { notifyOnFailure: false }) + ).toBe(false) + }) + expect(toastError).not.toHaveBeenCalled() + expect(updates).toHaveLength(0) + } + ) +}) + +describe('comment action provider failures', () => { + it.each(['refused', 'threw', 'succeeded'] as const)( + 'keeps reactions optimistic and handles a provider that %s', + async (outcome) => { + const pending = Promise.withResolvers() + const input = makeInput({ setPRCommentReaction: vi.fn(() => pending.promise) }) + const { result } = renderActions(input) + let action: Promise | undefined + act(() => { + action = result.current.handleSetReaction(comments[0], '+1', true) + }) + + expect(result.current.comments[0].reactions).toEqual([ + { content: '+1', count: 3, viewerHasReacted: true } + ]) + expect(input.setPRCommentReaction).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'comment-node', + '+1', + true, + expect.objectContaining({ repoOwnerExecutionHostId: 'ssh:workspace-host' }) + ) + + await act(async () => { + if (outcome === 'threw') { + pending.reject(new Error('Provider unavailable')) + } else { + pending.resolve(outcome === 'succeeded') + } + expect(await action).toBe(outcome === 'succeeded') + }) + + if (outcome === 'succeeded') { + expect(result.current.comments[0].reactions?.[0].viewerHasReacted).toBe(true) + expect(toastError).not.toHaveBeenCalled() + } else { + expect(result.current.comments).toEqual(comments) + expect(toastError).toHaveBeenCalledOnce() + } + } + ) + + it.each(['github', 'gitlab'] as const)( + 'rolls back %s thread resolution when the provider refuses it', + async (provider) => { + const github = Promise.withResolvers() + const gitlab = Promise.withResolvers() + const resolveMRDiscussion = vi.fn(() => gitlab.promise) + vi.stubGlobal('api', { gl: { resolveMRDiscussion } }) + const input = makeInput({ + resolveReviewThread: vi.fn(() => github.promise), + activeGitLabReview: + provider === 'gitlab' + ? { + provider: 'gitlab', + number: 42, + title: 'MR', + state: 'open', + url: 'https://gitlab.com/acme/repo/-/merge_requests/42', + status: 'success', + updatedAt: '2026-10-01T00:00:00Z', + mergeable: 'MERGEABLE' + } + : null + }) + const { result } = renderActions(input) + let action: Promise | undefined + act(() => { + action = result.current.handleResolve('thread-1', true) + }) + expect(result.current.comments.every((comment) => comment.isResolved)).toBe(true) + + await act(async () => { + if (provider === 'github') { + github.resolve(false) + } else { + gitlab.resolve({ ok: false, error: 'Provider unavailable' }) + } + expect(await action).toBe(false) + }) + + expect(result.current.comments).toEqual(comments) + expect(toastError).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-mutations.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-mutations.tsx index 0d5ae2545ae..e98730c2d9c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-mutations.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-mutations.tsx @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import { toast } from 'sonner' import { mergePRCommentIntoList } from '@/store/github/pr-comment-cache' import { githubProjectHost } from '../../../../../shared/github/project-identity' @@ -18,9 +19,11 @@ import { isMutablePRConversationComment } from '../../../../../shared/github/pr- import type { GitHubReactionContent, PRComment } from '../../../../../shared/github/comment-types' import { translate } from '@/i18n/i18n' import { codeHostErrorText } from '@/lib/code-host-error-copy' +import { errorMessage } from '../../../../../shared/error-message' type ChecksPanelCommentMutationsInput = Pick< ChecksPanelControllerState, + | 'activeWorktree' | 'addPRConversationComment' | 'addPRReviewCommentReply' | 'branch' @@ -36,6 +39,7 @@ type ChecksPanelCommentMutationsInput = Pick< export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutationsInput) { const { + activeWorktree, addPRConversationComment, addPRReviewCommentReply, branch, @@ -62,8 +66,17 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation pr.prRepo, pr.headSha ) + let repoOwnerExecutionHostId: string + try { + repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + } catch (error) { + const failure = { ok: false as const, error: errorMessage(error) } + toast.error(codeHostErrorText(failure)) + return failure + } const result = await addPRConversationComment(repo.path, prNumber, body, { repoId: repo.id, + repoOwnerExecutionHostId, prRepo: pr.prRepo }) if (!isCurrentAsyncResult(requestKey)) { @@ -77,6 +90,7 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation return { ok: true as const } }, [ + activeWorktree, addPRConversationComment, branch, commentsDisabledReason, @@ -165,21 +179,28 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation pr.headSha ) const previousReaction = comment.reactions?.find((reaction) => reaction.content === content) - setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted)) - const ok = await setPRCommentReaction( - repo.path, - prNumber, - reactionSubjectId, - content, - reacted, - { repoId: repo.id, prRepo: pr.prRepo } - ) + let optimisticApplied = false + let ok: boolean + try { + const repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted)) + optimisticApplied = true + ok = await setPRCommentReaction(repo.path, prNumber, reactionSubjectId, content, reacted, { + repoId: repo.id, + repoOwnerExecutionHostId, + prRepo: pr.prRepo + }) + } catch { + ok = false + } if (!isCurrentAsyncResult(requestKey) || ok) { return ok } - setComments((current) => - restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) - ) + if (optimisticApplied) { + setComments((current) => + restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) + ) + } toast.error( translate( 'auto.components.right.sidebar.ChecksPanel.updateReactionFailed', @@ -189,6 +210,7 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation return false }, [ + activeWorktree, branch, isCurrentAsyncResult, pr, @@ -219,9 +241,20 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation parent: comment, existingComments: commentsRef.current }) ?? comment.threadId + let repoOwnerExecutionHostId: string + try { + repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + } catch (error) { + const failure = { ok: false as const, error: errorMessage(error) } + if (notifyOnFailure) { + toast.error(codeHostErrorText(failure)) + } + return failure + } const result = canPostPRReviewThreadReply(comment) ? await addPRReviewCommentReply(repo.path, prNumber, comment.id, body, { repoId: repo.id, + repoOwnerExecutionHostId, prRepo: pr.prRepo, threadId: parentThreadId, path: comment.path, @@ -233,6 +266,7 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation buildPRCommentConversationReplyBody(comment.author, body), { repoId: repo.id, + repoOwnerExecutionHostId, prRepo: pr.prRepo } ) @@ -256,6 +290,7 @@ export function useChecksPanelCommentMutations(model: ChecksPanelCommentMutation return { ok: true as const } }, [ + activeWorktree, addPRConversationComment, addPRReviewCommentReply, branch, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx index 93c0d1743e9..b7ac0f9fba1 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx @@ -24,6 +24,8 @@ import { useAppStore } from '@/store' import { isReviewThreadResolutionInFlight } from '@/store/github/review-thread-resolution-in-flight' import { worktreeLaunchWorkspace } from '@/lib/agent-launch-route-input' import { codeHostErrorText } from '@/lib/code-host-error-copy' +import { errorMessage } from '../../../../../shared/error-message' +import type { GitLabDiscussionResolveResult } from '../../../../../shared/gitlab-types' type ChecksPanelCommentResolutionInput = Pick< ChecksPanelControllerState, @@ -84,20 +86,28 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut } if (repo && activeGitLabReview) { let previousThreadComments: PRComment[] = [] - setComments((prev) => { - previousThreadComments = prev.filter((comment) => comment.threadId === threadId) - return markPRCommentThreadResolved(prev, threadId, resolve) - }) - const result = await resolveGitLabMRDiscussionForChecks({ - repoPath: repo.path, - repoId: repo.id, - iid: activeGitLabReview.number, - discussionId: threadId, - resolved: resolve, - repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) - }) + let result: GitLabDiscussionResolveResult + try { + const repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + setComments((prev) => { + previousThreadComments = prev.filter((comment) => comment.threadId === threadId) + return markPRCommentThreadResolved(prev, threadId, resolve) + }) + result = await resolveGitLabMRDiscussionForChecks({ + repoPath: repo.path, + repoId: repo.id, + iid: activeGitLabReview.number, + discussionId: threadId, + resolved: resolve, + repoOwnerExecutionHostId + }) + } catch (error) { + result = { ok: false, error: errorMessage(error) } + } if (!result.ok) { - rollbackThread(previousThreadComments) + if (previousThreadComments.length > 0) { + rollbackThread(previousThreadComments) + } if (notifyOnFailure) { toast.error(codeHostErrorText(result)) } @@ -120,19 +130,27 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut pr?.headSha ) let previousThreadComments: PRComment[] = [] - setComments((prev) => { - previousThreadComments = prev.filter((comment) => comment.threadId === threadId) - return markPRCommentThreadResolved(prev, threadId, resolve) - }) - const ok = await resolveReviewThread(repo.path, prNumber, threadId, resolve, { - repoId: repo.id, - prRepo: pr?.prRepo - }) + let ok: boolean + try { + const repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, activeWorktree) + setComments((prev) => { + previousThreadComments = prev.filter((comment) => comment.threadId === threadId) + return markPRCommentThreadResolved(prev, threadId, resolve) + }) + ok = await resolveReviewThread(repo.path, prNumber, threadId, resolve, { + repoId: repo.id, + repoOwnerExecutionHostId, + prRepo: pr?.prRepo + }) + } catch { + ok = false + } if (!ok) { // Why: a head push mid-request must not strand the optimistic state the cache re-read copied in. if ( + previousThreadComments.length > 0 && checksPanelReviewStableKey(asyncResultKeyRef.current) === - checksPanelReviewStableKey(requestKey) + checksPanelReviewStableKey(requestKey) ) { rollbackThread(previousThreadComments) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx index 3ab16179f5b..8cc4e2185af 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { useEffect } from 'react' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' @@ -56,6 +57,7 @@ export function useChecksPanelConflictRefresh(model: ChecksPanelConflictRefreshI void fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber ?? pr.number, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx index 9f4701e09a6..84fe53b19fa 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState, type SetStateAction } from 'rea import { useWorktreeGitOwnerTarget } from '../worktree-git-owner-target' import { useWorktreeGitCopyHostId } from '@/lib/worktree-git-copy' import { useAppStore, type AppState } from '@/store' -import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { useActiveWorktree, selectReviewRepositoryForWorkspace } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from '../use-checks-panel-terminal-worktree' import { getConnectionId } from '@/lib/connection-context' import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' @@ -42,7 +42,7 @@ export function useChecksPanelControllerState() { isPanelVisible }) const activeWorktreeId = activeWorktree?.id ?? null - const repo = useRepoById(activeWorktree?.repoId ?? null) + const repo = useAppStore((s) => selectReviewRepositoryForWorkspace(s, activeWorktree)) const activeConnectionId = activeWorktreeId ? (getConnectionId(activeWorktreeId) ?? repo?.connectionId ?? null) : null diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx index 6451edc3662..034614b0a8b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import React, { useCallback, useEffect, useRef } from 'react' @@ -107,6 +108,7 @@ export function useChecksPanelEntryRefreshAndTitleActions( void fetchHostedReviewForBranch(repo.path, branch, { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), linkedGitHubPR: linkedPR, fallbackGitHubPR: fallbackGitHubPRNumber, currentHeadOid: activeWorktree?.head ?? null, @@ -209,6 +211,7 @@ export function useChecksPanelEntryRefreshAndTitleActions( const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, { repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), branch, userRefresh: true, linkedGitHubPR: linkedPR, @@ -232,6 +235,7 @@ export function useChecksPanelEntryRefreshAndTitleActions( const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber, @@ -240,6 +244,7 @@ export function useChecksPanelEntryRefreshAndTitleActions( await refreshHostedReviewCard(fetchHostedReviewForBranch, { repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), branch, userRefresh: true, linkedGitHubPR: linkedPR, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx index 141194a301d..1e3db25d8ab 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { useEffect, useRef } from 'react' import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client' import { getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-sync-client' @@ -310,6 +311,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect void getHostedReviewCreationEligibility({ repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}), branch, base: repo.worktreeBaseRef ?? null, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 2aa55e5fe26..d1c300c637a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { useCallback } from 'react' import { useAppStore } from '@/store' import { buildGitHubPRRefreshStateClearToken } from '@/store/github/pr-refresh-state' @@ -72,6 +73,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput if (!repo || !branch) { return } + const repoOptions = { repoId: repo.id, repoOwnerExecutionHostId: getRepoExecutionHostId(repo) } if (refreshInFlightRef.current) { return } @@ -192,9 +194,8 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput return } const refreshStoreState = useAppStore.getState() - const rawPRRefreshState = refreshStoreState.prRefreshStates[prCacheKey] const startedPRRefreshToken = buildGitHubPRRefreshStateClearToken( - rawPRRefreshState, + refreshStoreState.prRefreshStates[prCacheKey], refreshStoreState.prRefreshSequences, prCacheKey ) @@ -202,7 +203,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput try { refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, - repoId: repo.id, + ...repoOptions, worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber, @@ -255,7 +256,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput branch, refreshedPR.headSha, refreshedPR.prRepo, - { force: true, repoId: repo.id, wakeWslDistro: true, userRefresh: true } + { force: true, ...repoOptions, wakeWslDistro: true, userRefresh: true } ).then( (result) => { if (!isCurrentRequest() || !isCurrentAsyncResult(prRequestKey)) { @@ -283,7 +284,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput setCommentsLoading(true) const refreshedComments = fetchPRComments(repo.path, refreshedPR.number, { force: true, - repoId: repo.id, + ...repoOptions, prRepo: refreshedPR.prRepo, userRefresh: true }).then( diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx index 98459eab4c8..d98e57591b1 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx @@ -38,6 +38,11 @@ import { useChecksPanelPolling } from './use-checks-panel-polling' import { createModel } from './checks-panel-polling-test-model' +const baseRepo = createModel().repo +if (!baseRepo) { + throw new Error('The polling fixture requires a repository.') +} + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1_000_000) @@ -171,8 +176,9 @@ describe('useChecksPanelPolling live behavior', () => { expect(model.fetchPRChecks).not.toHaveBeenCalled() }) - it('uses the workspace owner and missing head override for a replacement MR', async () => { + it('uses the selected repository owner and missing head override for a replacement MR', async () => { const model = createModel({ + repo: { ...baseRepo, executionHostId: 'runtime:owner-runtime' }, activeGitLabReview: { provider: 'gitlab', number: 17, @@ -210,6 +216,7 @@ describe('useChecksPanelPolling live behavior', () => { it('reports a failed MR read as an error naming the machine, not as no checks (#24264)', async () => { gitlab.fetchDetails.mockRejectedValue(new Error('glab: not logged in')) const model = createModel({ + repo: { ...baseRepo, connectionId: 'devbox' }, activeGitLabReview: { provider: 'gitlab', number: 17, @@ -238,6 +245,17 @@ describe('useChecksPanelPolling live behavior', () => { ) }) + it('reports conflicting repository/workspace context through the normal MR error path', async () => { + const model = createModel({ + activeWorktree: makeWorktree({ id: 'worktree-1', repoId: 'repo-1', hostId: 'runtime:other' }) + }) + const { result } = renderHook(() => useChecksPanelPolling(model)) + await act(async () => result.current.fetchGitLabDetails({ mrNumberOverride: 18 })) + expect(gitlab.fetchDetails).not.toHaveBeenCalled() + expect(model.setChecksError).toHaveBeenCalledWith(expect.stringContaining('owning host')) + expect(model.setCommentsError).toHaveBeenCalledWith(expect.stringContaining('owning host')) + }) + it('drops replacement MR details when the relink scope changes in flight', async () => { let resolveDetails!: (value: { item: { projectRef: null } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx index 5e3c8c7b337..7930cd5d37c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { useChecksDetailTimer } from './use-checks-detail-timer' import { ChecksDetailPollingPolicy } from './checks-detail-polling-policy' import { useCallback, useEffect, useRef, useState } from 'react' @@ -81,7 +82,6 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { prCacheKey, fetchPRChecks, isCurrentAsyncResult, - activeWorktree, setChecksLoading, setChecks, setChecksError, @@ -111,6 +111,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { force, throwOnError: true, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), ...(wakeWslDistro ? { wakeWslDistro: true } : {}) } ) @@ -138,9 +139,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { if (!isWslDistroNotRunningRefusal(err)) { console.warn('Failed to fetch PR checks:', err) } - setChecksError( - checksPanelLoadErrorMessage(err, forgeOwnerHostIdForWorkspace(repo, activeWorktree)) - ) + setChecksError(checksPanelLoadErrorMessage(err, getRepoExecutionHostId(repo))) policyRef.current.fail() pollIntervalRef.current = Math.min(Math.max(60_000, pollIntervalRef.current * 2), 900_000) } finally { @@ -244,10 +243,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { console.warn('Failed to fetch GitLab MR checks:', err) } // One MR details request contains both pipeline and discussion; report failure for both. - const message = checksPanelLoadErrorMessage( - err, - forgeOwnerHostIdForWorkspace(repo, activeWorktree) - ) + const message = checksPanelLoadErrorMessage(err, getRepoExecutionHostId(repo)) setChecksError(message) setCommentsError(message) policyRef.current.fail() diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx index ab68a02f5dc..aa4eec86845 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../../shared/execution-host' import { useCallback, useEffect } from 'react' import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key' import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' @@ -77,6 +78,7 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { const result = await fetchPRComments(repo.path, targetPRNumber, { force, repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), prRepo: targetPRRepo, ...(userRefresh ? { userRefresh: true } : {}) }) @@ -94,9 +96,7 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { } console.warn('Failed to fetch PR comments:', err) setComments([]) - setCommentsError( - checksPanelLoadErrorMessage(err, forgeOwnerHostIdForWorkspace(repo, activeWorktree)) - ) + setCommentsError(checksPanelLoadErrorMessage(err, getRepoExecutionHostId(repo))) } finally { if ( isCurrentAsyncResult( @@ -118,8 +118,7 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { isCurrentAsyncResult, setCommentsLoading, setComments, - setCommentsError, - activeWorktree + setCommentsError ] ) @@ -146,7 +145,7 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { url: check.url, prRepo: pr?.prRepo ?? null }, - { repoId: repo.id } + { repoId: repo.id, repoOwnerExecutionHostId: getRepoExecutionHostId(repo) } ) }, [fetchPRCheckDetails, pr?.prRepo, repo, activeWorktree, gitLabProjectRefRef] @@ -172,7 +171,11 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { pr?.headSha ) setCommentsLoading(true) - void fetchPRComments(repo.path, prNumber, { repoId: repo.id, prRepo: pr?.prRepo }).then( + void fetchPRComments(repo.path, prNumber, { + repoId: repo.id, + repoOwnerExecutionHostId: getRepoExecutionHostId(repo), + prRepo: pr?.prRepo + }).then( (result) => { if (!cancelled && isCurrentAsyncResult(requestKey)) { setComments(result) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts index 201399ae676..81137fbe8be 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts @@ -18,6 +18,7 @@ export type SnapshottedThreadResolverDeps = { resolve: boolean, options: { repoId: string + repoOwnerExecutionHostId?: string prRepo?: PendingPRCommentAiAckGithubResolveTarget['prRepo'] } ) => Promise @@ -77,6 +78,7 @@ async function resolveOnHost( const target = deps.githubResolveTarget const ok = await deps.resolveReviewThread(target.repoPath, target.prNumber, threadId, true, { repoId: target.repoId, + repoOwnerExecutionHostId: target.ownerHostId, prRepo: target.prRepo }) return { ok } diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index 4270933eca7..2294814fc07 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -246,6 +246,7 @@ async function postInThreadFixingReply( /** Pin resolve targets at queue time; prRepo remains optional so degraded PR cache entries can still acknowledge threads. */ export type PendingPRCommentAiAckGithubResolveTarget = { + ownerHostId?: string repoPath: string repoId: string prNumber: number diff --git a/src/renderer/src/lib/repository-owner-index.ts b/src/renderer/src/lib/repository-owner-index.ts new file mode 100644 index 00000000000..aaf73ace2ea --- /dev/null +++ b/src/renderer/src/lib/repository-owner-index.ts @@ -0,0 +1,107 @@ +import type { Repo } from '../../../shared/repo-types' +import { + getRepoExecutionHostId, + getRepoSshConnectionId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' + +type RepoOwnerRecord = Pick + +type RepositoryEvidence = { + position: number | null + directPosition?: number | null + hostId: ExecutionHostId | null +} +type RepositoryEvidenceIndex = { + rows: Map + records: Set +} +const repoEvidenceCache = new WeakMap() + +function evidenceKey( + repoId: string, + hostId?: ExecutionHostId, + physicalHostId?: ExecutionHostId +): string { + return JSON.stringify([repoId, hostId ?? null, physicalHostId ?? null]) +} + +function repositoryEvidence(repos: readonly RepoOwnerRecord[]): RepositoryEvidenceIndex { + const cached = repoEvidenceCache.get(repos) + if (cached) { + return cached + } + const rows = new Map() + const add = (key: string, position: number, hostId: ExecutionHostId, direct: boolean) => { + const current = rows.get(key) + if (!current) { + rows.set(key, { position, hostId, ...(direct ? { directPosition: position } : {}) }) + return + } + current.position = null + if (current.hostId !== hostId) { + current.hostId = null + } + if (direct) { + current.directPosition = current.directPosition === undefined ? position : null + } + } + repos.forEach((repo, position) => { + const hostId = getRepoExecutionHostId(repo) + const connectionId = getRepoSshConnectionId(repo) + const physicalHostId = connectionId ? toSshExecutionHostId(connectionId) : hostId + add(evidenceKey(repo.id), position, hostId, false) + add(evidenceKey(repo.id, hostId), position, hostId, true) + if (physicalHostId !== hostId) { + add(evidenceKey(repo.id, physicalHostId), position, hostId, false) + } + add(evidenceKey(repo.id, hostId, physicalHostId), position, hostId, false) + }) + const index = { rows, records: new Set(repos) } + repoEvidenceCache.set(repos, index) + return index +} + +/** Keep connection evidence through resolution; implicit SSH recovery prefers the direct repository. */ +export function findIndexedReviewRepository( + repos: readonly T[], + repoId: string, + hostId?: ExecutionHostId, + physicalHostId?: ExecutionHostId +): T | null { + const evidence = repositoryEvidence(repos).rows.get(evidenceKey(repoId, hostId, physicalHostId)) + const position = + evidence?.directPosition !== undefined ? evidence.directPosition : evidence?.position + return position == null ? null : repos[position] +} + +/** Host-only downstream APIs must reject records distinguished by nested SSH connection. */ +export function hasUniqueIndexedRepositoryHost( + repos: readonly RepoOwnerRecord[] | undefined, + repoId: string, + hostId: ExecutionHostId +): boolean { + const evidence = repos && repositoryEvidence(repos).rows.get(evidenceKey(repoId, hostId)) + return evidence?.directPosition != null +} + +export function hasIndexedRepository( + repos: readonly RepoOwnerRecord[], + repository: RepoOwnerRecord +): boolean { + return repositoryEvidence(repos).records.has(repository) +} + +export function resolveIndexedSshRepositoryHost( + repos: readonly RepoOwnerRecord[], + repoId: string, + hostId: ExecutionHostId +): { kind: 'resolved'; hostId: ExecutionHostId } | { kind: 'missing' | 'ambiguous' } { + const evidence = repositoryEvidence(repos).rows.get(evidenceKey(repoId, hostId)) + return !evidence + ? { kind: 'missing' } + : evidence.hostId + ? { kind: 'resolved', hostId: evidence.hostId } + : { kind: 'ambiguous' } +} diff --git a/src/renderer/src/lib/review-owner.test.ts b/src/renderer/src/lib/review-owner.test.ts new file mode 100644 index 00000000000..d1f879642b2 --- /dev/null +++ b/src/renderer/src/lib/review-owner.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { resolveReviewRepository } from './review-owner' +import { resolveExactWorktreeRoute } from './worktree-owner-route' +import { forgeOwnerHostIdForWorkspace } from '../runtime/forge-credential-target' +import { findRepoForGitHubOwner } from '../store/github/repository-routing' + +const proxy: Repo = { + id: 'project', + path: '/project', + displayName: 'Project', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:server', + connectionId: 'builder' +} +const row = { id: 'project::/worktree', repoId: 'project', hostId: 'ssh:builder' as const } + +describe('review repository evidence', () => { + it.each(['local', 'ssh:other'] as const)( + 'ignores a stale connection beside explicit %s ownership', + (executionHostId) => { + const stale = { ...proxy, executionHostId } + expect(resolveReviewRepository([stale], row)).toBeNull() + expect(resolveReviewRepository([stale], row, stale)).toBeNull() + expect(() => forgeOwnerHostIdForWorkspace(stale, row)).toThrow('owning host') + expect(resolveReviewRepository([stale, proxy], row)).toBe(proxy) + expect(resolveExactWorktreeRoute({ repos: [stale, proxy] }, row)).toMatchObject({ + kind: 'resolved', + route: { runtimeEnvironmentId: 'server' } + }) + const canonicalWorkspace = { ...row, hostId: executionHostId } + expect(resolveReviewRepository([stale], canonicalWorkspace)).toBe(stale) + expect(forgeOwnerHostIdForWorkspace(stale, canonicalWorkspace)).toBe(executionHostId) + } + ) + + it('keeps generic ambiguity and direct review precedence while validating explicit context', () => { + const direct = { ...proxy, executionHostId: 'ssh:builder' as const } + const repos = [direct, proxy] + expect(resolveExactWorktreeRoute({ repos }, row).kind).toBe('ambiguous') + expect(resolveReviewRepository(repos, row)).toBe(direct) + expect(resolveReviewRepository(repos, row, proxy)).toBe(proxy) + expect(forgeOwnerHostIdForWorkspace(proxy, row)).toBe('runtime:server') + expect( + findRepoForGitHubOwner( + { repos }, + proxy.id, + proxy.path, + forgeOwnerHostIdForWorkspace(proxy, row) + ) + ).toBe(proxy) + expect(() => forgeOwnerHostIdForWorkspace({ ...proxy, connectionId: 'other' }, row)).toThrow( + 'owning host' + ) + expect( + resolveReviewRepository(repos, { ...row, runtimeOwnerEnvironmentId: 'missing' }) + ).toBeNull() + }) + + it('retains connection evidence but refuses a host-only consumer for same-runtime repository twins', () => { + const other = { ...proxy, connectionId: 'other' } + const repos = [proxy, other] + expect(resolveReviewRepository(repos, row)).toBe(proxy) + expect(() => + findRepoForGitHubOwner( + { repos }, + proxy.id, + proxy.path, + forgeOwnerHostIdForWorkspace(proxy, row) + ) + ).toThrow('owning host') + expect(resolveReviewRepository(repos, { ...row, runtimeOwnerEnvironmentId: 'server' })).toBe( + proxy + ) + }) + + it('invalidates missing, resolved and ambiguous evidence with the repository snapshot', () => { + const rival = { ...proxy, executionHostId: 'runtime:rival' as const } + expect(resolveReviewRepository([], row)).toBeNull() + expect(resolveReviewRepository([proxy], row)).toBe(proxy) + expect(resolveReviewRepository([proxy, rival], row)).toBeNull() + expect(resolveReviewRepository([rival], row)).toBe(rival) + expect(resolveExactWorktreeRoute({ repos: [proxy, rival] }, row).kind).toBe('ambiguous') + expect(resolveExactWorktreeRoute({ repos: [rival] }, row)).toMatchObject({ + kind: 'resolved', + route: { runtimeEnvironmentId: 'rival' } + }) + }) + + it.each(['distinct', 'competing'] as const)( + 'does not re-enumerate a warmed %s repository snapshot as workspace count increases', + (mode) => { + let reads = 0 + const repos = Array.from({ length: 300 }, (_, i) => ({ + ...proxy, + executionHostId: `runtime:server-${i}` as const, + get id() { + reads++ + return mode === 'distinct' ? `project-${i}` : 'project-299' + } + })) + const workspace = { ...row, repoId: 'project-299' } + resolveReviewRepository(repos, workspace) + resolveExactWorktreeRoute({ repos }, workspace) + reads = 0 + for (let i = 0; i < 400; i++) { + expect(resolveReviewRepository(repos, workspace)).toBe( + mode === 'distinct' ? repos[299] : null + ) + expect(resolveExactWorktreeRoute({ repos }, workspace).kind).toBe( + mode === 'distinct' ? 'resolved' : 'ambiguous' + ) + } + expect(reads).toBeLessThan(400 * 4) + } + ) +}) diff --git a/src/renderer/src/lib/review-owner.ts b/src/renderer/src/lib/review-owner.ts new file mode 100644 index 00000000000..b276ed53131 --- /dev/null +++ b/src/renderer/src/lib/review-owner.ts @@ -0,0 +1,61 @@ +import type { Repo } from '../../../shared/repo-types' +import type { Worktree } from '../../../shared/worktree/types' +import { + getRepoExecutionHostId, + getRepoSshConnectionId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import { findIndexedReviewRepository, hasIndexedRepository } from './repository-owner-index' + +type ReviewWorkspaceOwner = Pick + +export function reviewRepositoryMatchesWorkspace( + repo: Pick, + workspace: Pick +): boolean { + const owner = workspace.runtimeOwnerEnvironmentId?.trim() + const host = normalizeExecutionHostId(workspace.hostId) + if (owner && getRepoExecutionHostId(repo) !== toRuntimeExecutionHostId(owner)) { + return false + } + if (!host || host === getRepoExecutionHostId(repo)) { + return true + } + const connectionId = getRepoSshConnectionId(repo) + return ( + parseExecutionHostId(host)?.kind === 'ssh' && + Boolean(connectionId && toSshExecutionHostId(connectionId) === host) + ) +} + +/** Explicit repository context is authoritative when consistent; direct SSH wins implicit recovery. */ +export function resolveReviewRepository( + repos: readonly Repo[], + workspace: ReviewWorkspaceOwner, + selectedRepo?: Repo +): Repo | null { + if (selectedRepo) { + return selectedRepo.id === workspace.repoId && + hasIndexedRepository(repos, selectedRepo) && + reviewRepositoryMatchesWorkspace(selectedRepo, workspace) + ? selectedRepo + : null + } + const owner = workspace.runtimeOwnerEnvironmentId?.trim() + const host = normalizeExecutionHostId(workspace.hostId) + const repo = + findIndexedReviewRepository( + repos, + workspace.repoId, + owner ? toRuntimeExecutionHostId(owner) : (host ?? undefined), + owner && parseExecutionHostId(host)?.kind === 'ssh' ? (host ?? undefined) : undefined + ) ?? + (!owner && !host + ? findIndexedReviewRepository(repos, workspace.repoId, LOCAL_EXECUTION_HOST_ID) + : null) + return repo && reviewRepositoryMatchesWorkspace(repo, workspace) ? repo : null +} diff --git a/src/renderer/src/lib/worktree-operation-route-index.test.ts b/src/renderer/src/lib/worktree-operation-route-index.test.ts new file mode 100644 index 00000000000..951647f6f22 --- /dev/null +++ b/src/renderer/src/lib/worktree-operation-route-index.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { + resolveWorktreeOperationRouteResultForHost, + type WorktreeOperationRouteState +} from './worktree-operation-route' + +const id = 'project::/workspace' +const owner = { + id, + repoId: 'project', + hostId: 'ssh:builder' as const, + runtimeOwnerEnvironmentId: 'server' +} +const rival = { ...owner, runtimeOwnerEnvironmentId: 'other' } +const resolve = (state: WorktreeOperationRouteState) => + resolveWorktreeOperationRouteResultForHost(state, id, 'ssh:builder') +const resolved = (runtimeEnvironmentId: string | null) => ({ + kind: 'resolved', + route: { executionHostId: 'ssh:builder', runtimeEnvironmentId } +}) + +it.each(['tracked', 'detected'] as const)( + 'invalidates SSH ownership evidence when a %s rival appears and disappears', + (catalog) => { + const state: WorktreeOperationRouteState = { worktreesByRepo: { project: [owner] } } + expect(resolve(state)).toEqual(resolved('server')) + const contested = { + ...state, + ...(catalog === 'tracked' + ? { worktreesByRepo: { project: [owner], other: [rival] } } + : { detectedWorktreesByRepo: { other: { worktrees: [rival] } } }) + } + expect(resolve(contested)).toEqual({ kind: 'ambiguous' }) + expect(resolve(contested)).toEqual({ kind: 'ambiguous' }) + expect( + resolve({ ...contested, worktreesByRepo: { project: [owner] }, detectedWorktreesByRepo: {} }) + ).toEqual(resolved('server')) + expect(resolve({ ...state, worktreesByRepo: {} })).toEqual(resolved(null)) + expect(resolve(state)).toEqual(resolved('server')) + } +) + +it('invalidates legacy SSH transport evidence with the repository snapshot', () => { + const repo = { + id: 'project', + connectionId: 'builder', + executionHostId: 'runtime:server' as const + } + const state: WorktreeOperationRouteState = { + worktreesByRepo: { project: [{ ...owner, runtimeOwnerEnvironmentId: undefined }] }, + repos: [repo] + } + expect(resolve(state)).toEqual(resolved('server')) + const otherRepo = { ...repo, executionHostId: 'runtime:other' as const } + expect(resolve({ ...state, repos: [otherRepo] })).toEqual(resolved('other')) + expect(resolve({ ...state, repos: [repo, otherRepo] })).toEqual({ kind: 'ambiguous' }) + expect(resolve({ ...state, repos: [] })).toEqual(resolved(null)) + expect(resolve(state)).toEqual(resolved('server')) +}) diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index f6f30fca340..a840f612e33 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -1,6 +1,5 @@ import type { AppState } from '@/store/types' import { - getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId @@ -11,6 +10,8 @@ import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { resolveExactWorktreeRoute } from './worktree-owner-route' import { findIndexedDetectedWorktrees, + findIndexedRepoOwnerForHost, + findIndexedWorktreeOwners, hasIndexedDetectedWorktree, resolveIndexedRepoOwner, resolveIndexedWorktreeOwner @@ -67,14 +68,9 @@ function ownerRecordsOnHost( executionHostId: ExecutionHostId ): WorktreeOperationOwnerRecord[] { const owners: WorktreeOperationOwnerRecord[] = [] - for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { - for (const worktree of worktrees) { - if ( - worktree.id === worktreeId && - parseExecutionHostId(worktree.hostId)?.id === executionHostId - ) { - owners.push(worktree) - } + for (const worktree of findIndexedWorktreeOwners(state.worktreesByRepo, worktreeId)) { + if (parseExecutionHostId(worktree.hostId)?.id === executionHostId) { + owners.push(worktree) } } for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { @@ -128,11 +124,7 @@ function isDirectListing( const repoId = getRepoIdFromWorktreeId(worktreeId) return ( hubOwners.environmentIds.size === 0 && - Boolean( - state.repos?.some( - (repo) => repo.id === repoId && getRepoExecutionHostId(repo) === executionHostId - ) - ) + Boolean(findIndexedRepoOwnerForHost(state.repos, repoId, executionHostId)) ) } @@ -214,12 +206,10 @@ export function getWorktreeOperationOwnerHostIds( worktreeId: string ): ExecutionHostId[] { const hostIds = new Set() - for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { - for (const worktree of worktrees) { - const hostId = worktree.id === worktreeId ? parseExecutionHostId(worktree.hostId)?.id : null - if (hostId) { - hostIds.add(hostId) - } + for (const worktree of findIndexedWorktreeOwners(state.worktreesByRepo, worktreeId)) { + const hostId = parseExecutionHostId(worktree.hostId)?.id + if (hostId) { + hostIds.add(hostId) } } for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { diff --git a/src/renderer/src/lib/worktree-owner-route.ts b/src/renderer/src/lib/worktree-owner-route.ts index a370eca651c..5d7287317a7 100644 --- a/src/renderer/src/lib/worktree-owner-route.ts +++ b/src/renderer/src/lib/worktree-owner-route.ts @@ -1,8 +1,5 @@ -import { - getRepoExecutionHostId, - parseExecutionHostId, - toSshExecutionHostId -} from '../../../shared/execution-host' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { resolveIndexedSshRepositoryHost } from './repository-owner-index' import type { ExecutionHostId } from '../../../shared/execution-host' import type { WorktreeOperationOwnerRecord, @@ -45,22 +42,12 @@ function resolveRepoRouteForSshOwner( if (!repos || !owner.hostId) { return { kind: 'missing' } } - const routes = new Map() - for (const repo of repos) { - if (repo.id !== owner.repoId) { - continue - } - const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null - if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { - continue - } - addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) + const evidence = resolveIndexedSshRepositoryHost(repos, owner.repoId, owner.hostId) + if (evidence.kind !== 'resolved') { + return evidence } - const route = routes.values().next().value - if (routes.size === 1 && route) { - return { kind: 'resolved', route } - } - return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } + const route = routeForOwner({ hostId: evidence.hostId }) + return route ? { kind: 'resolved', route } : { kind: 'missing' } } export function resolveExactWorktreeRoute( diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index 21bb391f080..3577f8f0773 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -26,7 +26,10 @@ type ProjectGroupOwnerRecord = Pick, - ReadonlyMap + { + entries: ReadonlyMap + ownersById: ReadonlyMap + } >() const folderWorkspaceOwnerIndexCache = new WeakMap< readonly FolderWorkspaceOwnerRecord[], @@ -41,7 +44,7 @@ const detectedWorktreeIndexCache = new WeakMap< ReadonlyMap >() -const NO_DETECTED_WORKTREES: readonly WorktreeOwnerRecord[] = [] +const NO_WORKTREE_OWNERS: readonly WorktreeOwnerRecord[] = [] type IndexedFolderWorkspaceOwnerResolution = | { kind: 'resolved'; owner: FolderWorkspaceOwnerRecord } @@ -99,7 +102,8 @@ export function findIndexedWorktreeOwnerForHost( ): T | null { const hostKey = `${worktreeId}\0${executionHostId}` resolveIndexedWorktreeOwner(worktreesByRepo, worktreeId) - const entry = worktreesByRepo && worktreeOwnerIndexCache.get(worktreesByRepo)?.get(hostKey) + const entry = + worktreesByRepo && worktreeOwnerIndexCache.get(worktreesByRepo)?.entries.get(hostKey) const row = entry?.kind === 'resolved' ? worktreesByRepo?.[entry.bucket]?.[entry.position] : null return row ?? null } @@ -162,19 +166,38 @@ export function resolveIndexedWorktreeOwner( let index = worktreeOwnerIndexCache.get(worktreesByRepo) if (!index) { const next = new Map() + const ownersById = new Map() for (const [bucket, worktrees] of Object.entries(worktreesByRepo)) { worktrees.forEach((worktree, position) => { const id = worktree.id + const owners = ownersById.get(id) + if (owners) { + owners.push(worktree) + } else { + ownersById.set(id, [worktree]) + } addWorktreeOwnerIndexEntry(next, id, worktree, { bucket, position }) for (const hostId of worktreeOwnerHostIds(worktree)) { addWorktreeOwnerIndexEntry(next, `${id}\0${hostId}`, worktree, { bucket, position }) } }) } - index = next + index = { entries: next, ownersById } worktreeOwnerIndexCache.set(worktreesByRepo, index) } - return index.get(worktreeId) ?? { kind: 'missing' } + return index.entries.get(worktreeId) ?? { kind: 'missing' } +} + +/** All tracked copies, including conflicting owners that a resolved lookup cannot return. */ +export function findIndexedWorktreeOwners( + worktreesByRepo: Record | undefined, + worktreeId: string +): readonly WorktreeOwnerRecord[] { + resolveIndexedWorktreeOwner(worktreesByRepo, worktreeId) + return ( + (worktreesByRepo && worktreeOwnerIndexCache.get(worktreesByRepo)?.ownersById.get(worktreeId)) ?? + NO_WORKTREE_OWNERS + ) } /** @@ -186,7 +209,7 @@ export function findIndexedDetectedWorktrees( worktreeId: string ): readonly WorktreeOwnerRecord[] { if (!detectedWorktreesByRepo) { - return NO_DETECTED_WORKTREES + return NO_WORKTREE_OWNERS } let index = detectedWorktreeIndexCache.get(detectedWorktreesByRepo) if (!index) { @@ -204,7 +227,7 @@ export function findIndexedDetectedWorktrees( index = next detectedWorktreeIndexCache.set(detectedWorktreesByRepo, index) } - return index.get(worktreeId) ?? NO_DETECTED_WORKTREES + return index.get(worktreeId) ?? NO_WORKTREE_OWNERS } export function hasIndexedDetectedWorktree( diff --git a/src/renderer/src/runtime/forge-credential-target.ts b/src/renderer/src/runtime/forge-credential-target.ts index 077b612fff4..f3efd6a369f 100644 --- a/src/renderer/src/runtime/forge-credential-target.ts +++ b/src/renderer/src/runtime/forge-credential-target.ts @@ -1,5 +1,6 @@ -import { getRepoExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../shared/execution-host' import { parseHostAuthorityKey } from '../../../shared/host-authority' +import { reviewRepositoryMatchesWorkspace } from '@/lib/review-owner' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { TaskSourceContext } from '../../../shared/task-source-context' @@ -47,9 +48,8 @@ export function forgeOwnerHostIdForWorkspace( repo: Pick, worktree: Pick | null | undefined ): string { - const serverId = worktree?.runtimeOwnerEnvironmentId?.trim() - if (serverId) { - return toRuntimeExecutionHostId(serverId) + if (worktree && !reviewRepositoryMatchesWorkspace(repo, worktree)) { + throw new Error('The repository does not match this workspace’s owning host.') } - return worktree?.hostId || getRepoExecutionHostId(repo) + return getRepoExecutionHostId(repo) } diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts index 969233a2e86..b4aa7ba8ca7 100644 --- a/src/renderer/src/store/github/cache-model.ts +++ b/src/renderer/src/store/github/cache-model.ts @@ -36,7 +36,10 @@ export type FetchOptions = { wakeWslDistro?: boolean } -export type RepoScopedFetchOptions = FetchOptions & { repoId?: string } +export type RepoScopedFetchOptions = FetchOptions & { + repoId?: string + repoOwnerExecutionHostId?: string +} export type GitHubPRFallbackSource = NonNullable diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts index c001a660779..f3c0bffbf96 100644 --- a/src/renderer/src/store/github/check-actions.ts +++ b/src/renderer/src/store/github/check-actions.ts @@ -13,6 +13,8 @@ import { } from '../../../../shared/github/check-details-deadline' import { syncPRChecksStatus } from '../slices/github-checks' import { isWslDistroNotRunningRefusal } from '../../../../shared/wsl-distro-not-running-refusal' +import { findRepoForGitHubOwner } from './repository-routing' +import { hostedReviewOwnerIpcArgs } from '../slices/hosted-review-cache-state' import { getPRChecksCacheTtl, prChecksCacheSuffix, @@ -42,8 +44,11 @@ export const createCheckActions = ( prRepo, options ): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId ) const repoId = options?.repoId ?? repo?.id const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) @@ -170,9 +175,9 @@ export const createCheckActions = ( headSha, prRepo: prRepo ?? null, noCache, + ...hostedReviewOwnerIpcArgs(options), sourceContext: options?.sourceContext, - ...(userRefresh ? { userRefresh: true } : {}), - ...(options?.wakeWslDistro ? { wakeWslDistro: true } : {}) + ...(userRefresh ? { userRefresh: true } : {}) }) // If this read fails, the read it replaced still lands its answer, which is newer than the cache. const checks = await reply.catch(async (err: unknown) => { @@ -238,8 +243,11 @@ export const createCheckActions = ( fetchPRCheckDetails: async (repoPath, args, options): Promise => { try { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId ) const repoId = options?.repoId ?? repo?.id const requestContext = getGitHubWorkItemRequestContext( @@ -276,6 +284,7 @@ export const createCheckActions = ( checkName: args.checkName, url: args.url, prRepo: args.prRepo ?? null, + ...hostedReviewOwnerIpcArgs(options), sourceContext: options?.sourceContext }) ) diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts index f14b6d94ca5..48c9b12af07 100644 --- a/src/renderer/src/store/github/comment-fetch-actions.ts +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -11,6 +11,8 @@ import { ownsInflightRequest } from './request-coordination' import { mergePRCommentIntoList } from './pr-comment-cache' +import { findRepoForGitHubOwner } from './repository-routing' +import { hostedReviewOwnerIpcArgs } from '../slices/hosted-review-cache-state' import { getGitHubRepoSourceSettings, getGitHubSourceTarget, @@ -22,8 +24,11 @@ export const createCommentFetchActions = ( get: Parameters>[1] ): Pick => ({ fetchPRComments: async (repoPath, prNumber, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId ) const repoId = options?.repoId ?? repo?.id const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) @@ -82,6 +87,7 @@ export const createCommentFetchActions = ( prNumber, prRepo: options?.prRepo ?? null, noCache: force, + ...hostedReviewOwnerIpcArgs(options), sourceContext: options?.sourceContext }) // If this read fails, the read it replaced still lands its answer, which is newer than the cache. diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts index 19d66e47c4d..2d925259b04 100644 --- a/src/renderer/src/store/github/comment-mutation-actions.ts +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -3,6 +3,8 @@ 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 { findRepoForGitHubOwner } from './repository-routing' +import { hostedReviewOwnerIpcArgs } from '../slices/hosted-review-cache-state' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' @@ -20,31 +22,33 @@ export const createCommentMutationActions = ( get: Parameters>[1] ): Pick => ({ addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings(get(), 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(), - getGitHubSourceTarget(get(), repo, options?.sourceContext), - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult try { - result = + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings(get(), 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(), + getGitHubSourceTarget(get(), repo, options?.sourceContext), + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const result: GitHubCommentResult = requestContext.target.kind === 'environment' ? await callRuntimeRpc( { kind: 'environment', environmentId: requestContext.target.environmentId }, @@ -71,40 +75,40 @@ export const createCommentMutationActions = ( path: options?.path, line: options?.line, prRepo: options?.prRepo ?? null, + ...hostedReviewOwnerIpcArgs(options), 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() - }) + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result } - }) - inflightCommentsRequests.get(cacheKey)?.postedComments.push(comment) - return { ok: true, comment } + 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() + }) + } + }) + inflightCommentsRequests.get(cacheKey)?.postedComments.push(comment) + return { ok: true, comment } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } }, setPRCommentReaction: async ( @@ -115,79 +119,31 @@ export const createCommentMutationActions = ( 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(), 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(), - getGitHubSourceTarget(get(), repo, options?.sourceContext), - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.setPRCommentReaction', - { - repo: requestContext.target.runtimeRepoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRCommentReaction({ - repoPath, - repoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - console.error('Failed to update PR comment reaction:', err) - } - if (!ok && previousComment) { + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings(get(), 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) { @@ -198,17 +154,73 @@ export const createCommentMutationActions = ( ...state.commentsCache, [cacheKey]: { ...entry, - data: restoreReactionOnSubject( - entry.data, - reactionSubjectId, - content, - previousReaction - ) + data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) } } } }) + + let ok = false + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + getGitHubSourceTarget(get(), repo, options?.sourceContext), + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.setPRCommentReaction', + { + repo: requestContext.target.runtimeRepoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRCommentReaction({ + repoPath, + repoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null, + ...hostedReviewOwnerIpcArgs(options), + 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 + } catch { + return false } - return ok } }) diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts index c438ef43f11..0a8a05fec8b 100644 --- a/src/renderer/src/store/github/conversation-comment-actions.ts +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -3,6 +3,8 @@ 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 { findRepoForGitHubOwner } from './repository-routing' +import { hostedReviewOwnerIpcArgs } from '../slices/hosted-review-cache-state' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { withBoundedCacheEntry } from './cache-policy' @@ -19,31 +21,33 @@ export const createConversationCommentActions = ( get: Parameters>[1] ): Pick => ({ addPRConversationComment: async (repoPath, prNumber, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings(get(), 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(), - getGitHubSourceTarget(get(), repo, options?.sourceContext), - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult try { - result = + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings(get(), 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(), + getGitHubSourceTarget(get(), repo, options?.sourceContext), + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const result: GitHubCommentResult = requestContext.target.kind === 'environment' ? await callRuntimeRpc( { kind: 'environment', environmentId: requestContext.target.environmentId }, @@ -64,33 +68,33 @@ export const createConversationCommentActions = ( body, type: 'pr', prRepo: options?.prRepo ?? null, + ...hostedReviewOwnerIpcArgs(options), 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() - }) + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result } - }) - inflightCommentsRequests.get(cacheKey)?.postedComments.push(result.comment) - return result + set((s) => { + const entry = s.commentsCache[cacheKey] + return { + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: mergePRCommentIntoList(entry?.data, result.comment), + fetchedAt: Date.now() + }) + } + }) + inflightCommentsRequests.get(cacheKey)?.postedComments.push(result.comment) + return result + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } } }) diff --git a/src/renderer/src/store/github/linked-review-clear.ts b/src/renderer/src/store/github/linked-review-clear.ts new file mode 100644 index 00000000000..189c9fa5e04 --- /dev/null +++ b/src/renderer/src/store/github/linked-review-clear.ts @@ -0,0 +1,108 @@ +import type { AppState } from '../types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { CLEARED_HOSTED_REVIEW_SELECTION_UPDATES } from '../../../../shared/worktree/hosted-review-links' +import { + shouldApplyBranchMismatchedLinkedPRClear, + shouldApplyDivergedLinkedPRClear +} from '../../../../shared/github/linked-pr-staleness' +import { getHostedReviewLinkMutationGeneration } from '../slices/worktrees/metadata/hosted-review-link-mutation' +import { tryRuntimeTargetForWorktreeOwner } from '../slices/worktrees/listing/worktree-owner-target' +import { hasUniqueIndexedRepositoryHost } from '@/lib/repository-owner-index' +import { resolveReviewRepository } from '@/lib/review-owner' +import { findWorktreeById, getWorktreeLookupIndex } from './worktree-refresh' + +export type LinkedReviewClearScope = { + repository: Repo + workspace: Worktree + linkMutationGeneration: number +} + +export function captureLinkedReviewClearScope( + state: AppState, + worktreeId: string | undefined, + repository: Repo | undefined +): LinkedReviewClearScope | undefined { + if ( + !worktreeId || + !repository || + !hasUniqueIndexedRepositoryHost(state.repos, repository.id, getRepoExecutionHostId(repository)) + ) { + return undefined + } + const workspace = findWorktreeById(state, worktreeId, getRepoExecutionHostId(repository)) + if (!workspace || !resolveReviewRepository(state.repos, workspace, repository)) { + return undefined + } + // The metadata API cannot distinguish two rows with the same physical host. + const copies = getWorktreeLookupIndex(state).byId.get(worktreeId)?.owners ?? [] + if (copies.filter((row) => row.hostId === workspace.hostId).length !== 1) { + return undefined + } + if ( + !tryRuntimeTargetForWorktreeOwner( + state, + workspace.id, + workspace.hostId ?? getRepoExecutionHostId(repository) + ) + ) { + return undefined + } + return { + repository, + workspace, + linkMutationGeneration: getHostedReviewLinkMutationGeneration(worktreeId) + } +} + +function sameWorkspace(original: Worktree, current: Worktree): boolean { + if (original === current) { + return true + } + const occupant = original.identity?.key ?? original.instanceId + return ( + Boolean(occupant) && + occupant === (current.identity?.key ?? current.instanceId) && + original.id === current.id && + original.repoId === current.repoId && + original.path === current.path && + original.hostId === current.hostId && + original.runtimeOwnerEnvironmentId === current.runtimeOwnerEnvironmentId && + original.createdAt === current.createdAt && + JSON.stringify(original.creatorProvenance) === JSON.stringify(current.creatorProvenance) + ) +} + +export function clearLinkedReview( + get: () => AppState, + scope: LinkedReviewClearScope | undefined, + args: { linkedPRNumber: number; branch: string; requestHeadOid: string | null }, + kind: 'diverged' | 'branch-mismatch', + suppressHostedReviewRefresh = false +): void { + if (!scope) { + return + } + const predicate = + kind === 'diverged' + ? shouldApplyDivergedLinkedPRClear + : shouldApplyBranchMismatchedLinkedPRClear + void get().updateWorktreeMeta(scope.workspace.id, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, { + executionHostId: scope.workspace.hostId ?? getRepoExecutionHostId(scope.repository), + ...(suppressHostedReviewRefresh ? { suppressHostedReviewRefresh: true } : {}), + shouldApply: (current) => { + const now = captureLinkedReviewClearScope(get(), scope.workspace.id, scope.repository) + return Boolean( + current && + now && + now.linkMutationGeneration === scope.linkMutationGeneration && + sameWorkspace(scope.workspace, now.workspace) && + sameWorkspace(scope.workspace, current) && + (kind !== 'branch-mismatch' || + JSON.stringify(current.pushTarget) === JSON.stringify(scope.workspace.pushTarget)) && + predicate({ worktree: current, ...args }) + ) + } + }) +} diff --git a/src/renderer/src/store/github/pr-for-branch-ipc-fallback.ts b/src/renderer/src/store/github/pr-for-branch-ipc-fallback.ts index 5b7928d99cb..0998d23e2fa 100644 --- a/src/renderer/src/store/github/pr-for-branch-ipc-fallback.ts +++ b/src/renderer/src/store/github/pr-for-branch-ipc-fallback.ts @@ -7,7 +7,7 @@ import type { export function lookupPRForBranchOverIpc( candidate: GitHubPRRefreshCandidate, reason: GitHubPRRefreshReason, - options: { userRefresh?: boolean } = {} + options: { userRefresh?: boolean; repoOwnerExecutionHostId?: string } = {} ): ReturnType { const fallbackPRNumber = candidate.fallbackPRNumber ?? null return window.api.gh.prForBranch({ @@ -20,6 +20,9 @@ export function lookupPRForBranchOverIpc( acceptMergedFallbackPR: fallbackPRNumber !== null && (candidate.fallbackPRSource ?? null) !== null, currentHeadOid: candidate.currentHeadOid ?? null, + ...(options.repoOwnerExecutionHostId + ? { repoOwnerExecutionHostId: options.repoOwnerExecutionHostId } + : {}), ...(reason === 'manual' ? { wakeWslDistro: true } : {}) }) } diff --git a/src/renderer/src/store/github/pr-refresh-state.ts b/src/renderer/src/store/github/pr-refresh-state.ts index 1193138c027..7496acbe1ed 100644 --- a/src/renderer/src/store/github/pr-refresh-state.ts +++ b/src/renderer/src/store/github/pr-refresh-state.ts @@ -56,7 +56,7 @@ export type PRRefreshStateClearToken = { updatedAt: number } -const PR_REFRESH_ACTIVE_STALE_MS = 120_000 +export const PR_REFRESH_ACTIVE_STALE_MS = 120_000 const PR_REFRESH_PAUSED_GRACE_MS = 5_000 const MAX_PR_REFRESH_STATE_ENTRIES = 2000 const SETTLED_PR_REFRESH_STATUSES = new Set(['error', 'skipped']) diff --git a/src/renderer/src/store/github/pr-result-cache.ts b/src/renderer/src/store/github/pr-result-cache.ts index 3b1e58f3a27..a543e7b9b8d 100644 --- a/src/renderer/src/store/github/pr-result-cache.ts +++ b/src/renderer/src/store/github/pr-result-cache.ts @@ -1,10 +1,16 @@ +import type { LinkedReviewClearScope } from './linked-review-clear' import type { AppState } from '../types' import type { RuntimeFocusSettings } from '../../../../shared/github/github-cache-key' import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' 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 { + prRefreshStartedHostedReviewEntries, + prRefreshStartedLinkedReviewScopes +} from './request-coordination' +import { PR_REFRESH_ACTIVE_STALE_MS } from './pr-refresh-state' import { shouldWritePRCacheForHostedReviewSync, syncHostedReviewCacheFromGitHubPRResult @@ -68,13 +74,38 @@ export function prRefreshStartedEntryKey(sequence: number, cacheKey: string): st export function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void { if (sequence !== undefined && sequence > 0) { prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey)) + if (prRefreshStartedLinkedReviewScopes.get(cacheKey)?.sequence === sequence) { + prRefreshStartedLinkedReviewScopes.delete(cacheKey) + } } } -export function setPRRefreshStartedHostedReviewEntry( - key: string, - entry: AppState['hostedReviewCache'][string] | undefined +export function pruneExpiredPRRefreshStartedEntries(now = performance.now()): void { + for (const [cacheKey, request] of prRefreshStartedLinkedReviewScopes) { + // Monotonic expiry order avoids scanning live aliases on every refresh event. + if (now <= request.expiresAt) { + break + } + deletePRRefreshStartedEntry(request.sequence, cacheKey) + } +} + +export function setPRRefreshStartedEntry( + sequence: number, + cacheKey: string, + entry: AppState['hostedReviewCache'][string] | undefined, + clearScope?: LinkedReviewClearScope ): void { + // Ownership evidence must survive advisory cache eviction for every accepted alias. + prRefreshStartedLinkedReviewScopes.delete(cacheKey) + if (clearScope) { + prRefreshStartedLinkedReviewScopes.set(cacheKey, { + sequence, + scope: clearScope, + expiresAt: performance.now() + PR_REFRESH_ACTIVE_STALE_MS + }) + } + const key = prRefreshStartedEntryKey(sequence, cacheKey) if (entry === undefined) { prRefreshStartedHostedReviewEntries.delete(key) return @@ -146,6 +177,7 @@ export function applyGitHubPRResultToCaches( nextPR: args.pr, state: args.state, worktreeId: args.worktreeId, + executionHostId: args.hasRepoOwner ? getRepoExecutionHostId(args) : undefined, linkedPRNumber: args.linkedPRNumber, rejectedPRUrls: args.rejectedPRUrls }) @@ -191,6 +223,7 @@ export function shouldPreserveExistingPRForFallbackMiss(args: { nextPR: PRInfo | null state: AppState worktreeId?: string + executionHostId?: string linkedPRNumber?: number | null rejectedPRUrls?: readonly string[] }): boolean { @@ -205,7 +238,9 @@ export function shouldPreserveExistingPRForFallbackMiss(args: { 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 worktree = args.worktreeId + ? findWorktreeById(args.state, args.worktreeId, args.executionHostId) + : 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 = diff --git a/src/renderer/src/store/github/pull-request-actions.ts b/src/renderer/src/store/github/pull-request-actions.ts index 022e8d1d6c9..ff537da9666 100644 --- a/src/renderer/src/store/github/pull-request-actions.ts +++ b/src/renderer/src/store/github/pull-request-actions.ts @@ -1,11 +1,10 @@ -import { CLEARED_HOSTED_REVIEW_SELECTION_UPDATES } from '../../../../shared/worktree/hosted-review-links' 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 { findRepoForGitHubOwner } from './repository-routing' import { prCacheKey } from './cache-identity' import { isFresh } from './cache-policy' import { buildGitHubPRRefreshStateClearToken } from './pr-refresh-state' @@ -13,11 +12,8 @@ import { githubHostedReviewFallbackPRNumber, prLookupHintKey } from './pr-result import { nextLookupGeneration } from '../lookup-generation-sequence' import { inflightPRRequests, prRequestGenerations } from './request-coordination' import { getGitHubSourceTarget, settingsForGitHubRepoOwner } from './work-item-routing' -import { findWorktreeById } from './worktree-refresh' -import { - shouldApplyDivergedLinkedPRClear, - shouldClearDivergedLinkedMergedPR -} from '../../../../shared/github/linked-pr-staleness' +import { captureLinkedReviewClearScope, clearLinkedReview } from './linked-review-clear' +import { shouldClearDivergedLinkedMergedPR } from '../../../../shared/github/linked-pr-staleness' import { startPullRequestLookup } from './pull-request-execution' export const createPullRequestActions = ( @@ -25,10 +21,12 @@ export const createPullRequestActions = ( get: Parameters>[1] ): Pick => ({ fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repoLookup = getGitHubRepoLookupIndex(get().repos) - const repo = options?.repoId - ? repoLookup.findById(options.repoId) - : repoLookup.findByPath(repoPath) + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId + ) const repoId = options?.repoId ?? repo?.id const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) const cacheKey = prCacheKey( @@ -79,7 +77,8 @@ export const createPullRequestActions = ( linkedPRNumber != null && cached?.data?.headDivergedFromMergedPRAtOid != null ) { - const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null + const scope = captureLinkedReviewClearScope(get(), options.worktreeId, repo) + const currentHeadOid = scope?.workspace.head ?? null if ( shouldClearDivergedLinkedMergedPR({ pr: cached.data, @@ -87,18 +86,11 @@ export const createPullRequestActions = ( requestHeadOid: currentHeadOid }) ) { - void get().updateWorktreeMeta( - options.worktreeId, - CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { - shouldApply: (worktree) => - shouldApplyDivergedLinkedPRClear({ - worktree, - linkedPRNumber, - branch, - requestHeadOid: currentHeadOid - }) - } + clearLinkedReview( + get, + scope, + { linkedPRNumber, branch, requestHeadOid: currentHeadOid }, + 'diverged' ) } } diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 74c7ecfa3bb..cdea31e8743 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -1,4 +1,3 @@ -import { CLEARED_HOSTED_REVIEW_SELECTION_UPDATES } from '../../../../shared/worktree/hosted-review-links' import type { StateCreator } from 'zustand' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import type { AppState } from '../types' @@ -26,17 +25,12 @@ import { isWslHostOffForForge, withForgeHostOffSkip } from './wsl-host-off-forge import { cachedPRCandidateFields } from './pr-refresh-cached-candidate-fields' import { lookupPRForBranchOverIpc } from './pr-for-branch-ipc-fallback' import { setGitHubPRResultCaches, shouldPreserveExistingPRForFallbackMiss } from './pr-result-cache' +import { findWorktreeById, isStaleExactLinkedPRLookup } from './worktree-refresh' import { - findUniqueWorktreeById, - findWorktreeById, - isStaleExactLinkedPRLookup -} from './worktree-refresh' -import { - shouldApplyBranchMismatchedLinkedPRClear, - shouldApplyDivergedLinkedPRClear, shouldClearBranchMismatchedLinkedOpenPR, shouldClearDivergedLinkedMergedPR } from '../../../../shared/github/linked-pr-staleness' +import { captureLinkedReviewClearScope, clearLinkedReview } from './linked-review-clear' export function startPullRequestLookup(args: { set: Parameters>[0] get: Parameters>[1] @@ -77,6 +71,8 @@ export function startPullRequestLookup(args: { requestStartedHostedReviewEntry, requestStartedPRRefreshToken } = args + const executionHostId = repo ? getRepoExecutionHostId(repo) : undefined + const ownerHostId = executionHostId ?? LOCAL_EXECUTION_HOST_ID const request = (async () => { try { const runtimeRepo = getRuntimeRepoTarget(get(), { repoId, repoPath }, requestTarget) @@ -87,8 +83,12 @@ export function startPullRequestLookup(args: { return cached?.data ?? null } const candidateWorktree = options?.worktreeId - ? findWorktreeById(get(), options.worktreeId) + ? findWorktreeById(get(), options.worktreeId, executionHostId) : null + const clearScope = + linkedPRNumber != null + ? captureLinkedReviewClearScope(get(), options?.worktreeId, repo) + : undefined const requestHeadOid = candidateWorktree?.head ?? null let transportFailed = false const response = runtimeRepo @@ -129,7 +129,10 @@ export function startPullRequestLookup(args: { const requestOptions = options?.userRefresh === true ? { userRefresh: true } : {} const response = window.api.gh.refreshPRNow ? await window.api.gh.refreshPRNow({ candidate, reason, ...requestOptions }) - : await lookupPRForBranchOverIpc(candidate, reason, requestOptions) + : await lookupPRForBranchOverIpc(candidate, reason, { + ...requestOptions, + repoOwnerExecutionHostId: options?.repoOwnerExecutionHostId + }) return normalizeGitHubPRForBranchOutcome(response) })() const outcome = await response.catch(() => { @@ -152,7 +155,7 @@ export function startPullRequestLookup(args: { let skippedStaleLinkedPRLookup = 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)) { + if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber, executionHostId)) { skippedStaleLinkedPRLookup = true return s } @@ -172,7 +175,7 @@ export function startPullRequestLookup(args: { { cacheKey, repoPath, branch, repoId, connectionId: repo?.connectionId }, pr, outcome.fetchedAt, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ownerHostId ) : pr, fetchedAt: outcome.fetchedAt, @@ -190,39 +193,16 @@ export function startPullRequestLookup(args: { if (skippedStaleLinkedPRLookup) { return null } - const linkedPRWorktree = - options?.worktreeId && linkedPRNumber != null - ? findUniqueWorktreeById( - get(), - options.worktreeId, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) - : null + const linkedPRWorktree = clearScope + ? findWorktreeById(get(), clearScope.workspace.id, executionHostId) + : 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, - CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById( - get(), - options.worktreeId!, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) ?? undefined, - linkedPRNumber, - branch, - requestHeadOid - }) - } - ) + clearLinkedReview(get, clearScope, { linkedPRNumber, branch, requestHeadOid }, 'diverged') } if ( options?.worktreeId && @@ -236,34 +216,24 @@ export function startPullRequestLookup(args: { pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null }) ) { - void get().updateWorktreeMeta( - options.worktreeId, - CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { - // 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 - }) - } + clearLinkedReview( + get, + clearScope, + { linkedPRNumber, branch, requestHeadOid }, + 'branch-mismatch', + true ) // 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, - reason: options.reason, - ...(options.userRefresh === true ? { userRefresh: true } : {}) - }) + void get() + .fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + repoOwnerExecutionHostId: executionHostId, + worktreeId: options.worktreeId, + reason: options.reason, + ...(options.userRefresh === true ? { userRefresh: true } : {}) + }) + .catch((err) => console.warn('Branch-scoped PR refetch skipped:', err)) } } if ( @@ -272,6 +242,7 @@ export function startPullRequestLookup(args: { nextPR: pr, state: get(), worktreeId: options?.worktreeId, + executionHostId, linkedPRNumber, rejectedPRUrls: outcome.kind === 'no-pr' ? outcome.rejectedPRUrls : undefined }) diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index 6792b1e07db..a8a17cfd7d8 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -1,4 +1,3 @@ -import { CLEARED_HOSTED_REVIEW_SELECTION_UPDATES } from '../../../../shared/worktree/hosted-review-links' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' @@ -13,18 +12,22 @@ import { applyGitHubPRResultToCaches, deletePRRefreshStartedEntry, prRefreshStartedEntryKey, - setPRRefreshStartedHostedReviewEntry + pruneExpiredPRRefreshStartedEntries, + setPRRefreshStartedEntry } from './pr-result-cache' -import { prRefreshStartedHostedReviewEntries } from './request-coordination' +import { + prRefreshStartedHostedReviewEntries, + prRefreshStartedLinkedReviewScopes +} from './request-coordination' +import { + captureLinkedReviewClearScope, + clearLinkedReview, + type LinkedReviewClearScope +} from './linked-review-clear' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' import { getRefreshAliasExecutionHostId } from './repository-routing' +import { buildWorktreeLookupIndex, isStaleExactLinkedPRLookup } from './worktree-refresh' import { - buildWorktreeLookupIndex, - findUniqueWorktreeById, - isStaleExactLinkedPRLookup -} from './worktree-refresh' -import { - shouldApplyBranchMismatchedLinkedPRClear, - shouldApplyDivergedLinkedPRClear, shouldClearBranchMismatchedLinkedOpenPR, shouldClearDivergedLinkedMergedPR } from '../../../../shared/github/linked-pr-staleness' @@ -35,20 +38,21 @@ export const createRefreshEventActions = ( get: Parameters>[1] ): Pick => ({ applyGitHubPRRefreshEvent: (event) => { + pruneExpiredPRRefreshStartedEntries() // 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 + scope: LinkedReviewClearScope }[] = [] const branchMismatchedLinkedPRClears: { worktreeId: string linkedPRNumber: number branch: string requestHeadOid: string | null - executionHostId: string + scope: LinkedReviewClearScope }[] = [] set((s) => { let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined @@ -61,7 +65,10 @@ export const createRefreshEventActions = ( for (const alias of event.aliases) { const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) - const previousSequence = nextSequences[alias.cacheKey] ?? 0 + const previousSequence = Math.max( + nextSequences[alias.cacheKey] ?? 0, + prRefreshStartedLinkedReviewScopes.get(alias.cacheKey)?.sequence ?? 0 + ) if ( event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence ) { @@ -78,7 +85,8 @@ export const createRefreshEventActions = ( if (event.outcome) { const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey) const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey) - prRefreshStartedHostedReviewEntries.delete(startedEntryKey) + const requestScope = prRefreshStartedLinkedReviewScopes.get(alias.cacheKey) + deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) if (previousSequence !== event.sequence) { deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) } @@ -113,23 +121,27 @@ export const createRefreshEventActions = ( : 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) + isStaleExactLinkedPRLookup( + s, + alias.worktreeId, + linkedPRNumber, + aliasExecutionHostId, + 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 + const scope = + requestScope?.sequence === event.sequence && + requestScope.scope.workspace.id === alias.worktreeId + ? requestScope.scope + : undefined + const worktree = scope?.workspace // Why: only the sequence-gate winner owns metadata side effects; late outcomes must not unlink a newer PR. if ( + scope && worktree && linkedPRNumber != null && shouldClearDivergedLinkedMergedPR({ @@ -143,9 +155,10 @@ export const createRefreshEventActions = ( linkedPRNumber, branch: alias.branch, requestHeadOid, - executionHostId: aliasExecutionHostId + scope }) } else if ( + scope && worktree && linkedPRNumber != null && shouldClearBranchMismatchedLinkedOpenPR({ @@ -161,7 +174,7 @@ export const createRefreshEventActions = ( linkedPRNumber, branch: alias.branch, requestHeadOid, - executionHostId: aliasExecutionHostId + scope }) } } @@ -208,9 +221,21 @@ export const createRefreshEventActions = ( aliasExecutionHostId, true ) - setPRRefreshStartedHostedReviewEntry( - prRefreshStartedEntryKey(event.sequence, alias.cacheKey), - s.hostedReviewCache[hostedReviewCacheKey] + setPRRefreshStartedEntry( + event.sequence, + alias.cacheKey, + s.hostedReviewCache[hostedReviewCacheKey], + alias.linkedPRNumber != null + ? captureLinkedReviewClearScope( + s, + alias.worktreeId, + getGitHubRepoLookupIndex(s.repos).findOnHost( + aliasExecutionHostId, + alias.repoId, + alias.repoPath + ) + ) + : undefined ) } else { // Why: pause/skip can follow an in-flight broadcast with no outcome; drop the stale request-start snapshot. @@ -244,28 +269,10 @@ export const createRefreshEventActions = ( : s }) for (const clear of divergedLinkedPRClears) { - void get().updateWorktreeMeta(clear.worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - }) + clearLinkedReview(get, clear.scope, clear, 'diverged') } for (const clear of branchMismatchedLinkedPRClears) { - void get().updateWorktreeMeta(clear.worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, { - shouldApply: () => - shouldApplyBranchMismatchedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - }) + clearLinkedReview(get, clear.scope, clear, 'branch-mismatch') } } }) diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts index e41b15a0d6f..92aa0cf80ca 100644 --- a/src/renderer/src/store/github/refresh-routing-actions.ts +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -2,13 +2,19 @@ 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 { getRepoExecutionHostId } from '../../../../shared/execution-host' import { bypassesGitHubPRRefreshFreshness } from './pr-refresh-state' import { enqueueLocalGitHubPRRefresh, getPRRefreshRuntimeRepoTarget, shouldEnqueueLocalPRRefresh } from './repository-routing' -import { buildPRRefreshCandidate, findWorktreeById } from './worktree-refresh' +import { + buildPRRefreshCandidate, + findWorktreeReviewOwner, + findWorktreeById +} from './worktree-refresh' +import { getVisibleHostedReviewWorkspaces } from './visible-hosted-review-refresh-targets' import { hasNonGitHubReview, shouldCoordinateVisibleGitHubReview @@ -32,20 +38,25 @@ export const createRefreshRoutingActions = ( : { visibleReviewCardWorktreeIds: ids } ) }, - enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { + enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0, executionHostId) => { const state = get() - const worktree = findWorktreeById(state, worktreeId) - const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null + const owner = findWorktreeReviewOwner(state, worktreeId, executionHostId) + if (!owner) { + return + } + const { worktree, repo } = owner + const candidate = buildPRRefreshCandidate(state, worktree, undefined, repo) if (!candidate) { return } - if (worktree && hasNonGitHubReview(state, worktree, candidate)) { + if (hasNonGitHubReview(state, worktree, candidate)) { return } if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, + repoOwnerExecutionHostId: getRepoExecutionHostId(candidate), worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, @@ -61,6 +72,7 @@ export const createRefreshRoutingActions = ( await get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, + repoOwnerExecutionHostId: getRepoExecutionHostId(candidate), worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, @@ -78,19 +90,16 @@ export const createRefreshRoutingActions = ( : { visibleReviewWorktreeIds: worktreeIds } ) const state = get() - const candidates = worktreeIds - .map((id) => { - const worktree = findWorktreeById(state, id) - const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null - return worktree && - candidate && - shouldCoordinateVisibleGitHubReview(state, worktree, candidate) - ? candidate - : null - }) - .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) const localCandidates: GitHubPRRefreshCandidate[] = [] - for (const candidate of candidates) { + for (const { worktree, candidate } of getVisibleHostedReviewWorkspaces(state, { + worktreeIds + })) { + if ( + findWorktreeById(state, worktree.id, getRepoExecutionHostId(candidate)) !== worktree || + !shouldCoordinateVisibleGitHubReview(state, worktree, candidate) + ) { + continue + } if (getPRRefreshRuntimeRepoTarget(state, candidate)) { continue } diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts index c4f18c8fd9c..900d30ba066 100644 --- a/src/renderer/src/store/github/refresh-sweep-actions.ts +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -1,11 +1,13 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' -import type { Worktree } from '../../../../shared/worktree/types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { buildPRRefreshCandidate, findWorktreeReviewOwner } from './worktree-refresh' import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' -import { issueCacheKey, prCacheKey } from './cache-identity' +import { issueCacheKey } from './cache-identity' import { CACHE_TTL, evictStaleEntries } from './cache-policy' import { pruneExpiredPRRefreshStates } from './pr-refresh-state' +import { pruneExpiredPRRefreshStartedEntries } from './pr-result-cache' import { settingsForGitHubRepoOwner } from './work-item-routing' import { selectWorktreesByRepo } from '../workspace-record-selectors' @@ -14,6 +16,7 @@ export const createRefreshSweepActions = ( get: Parameters>[1] ): Pick => ({ refreshAllGitHub: () => { + pruneExpiredPRRefreshStartedEntries() // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. set((s) => { const next = { @@ -74,34 +77,22 @@ export const createRefreshSweepActions = ( } }, - refreshGitHubForWorktree: (worktreeId) => { + refreshGitHubForWorktree: (worktreeId, executionHostId) => { const state = get() - let worktree: Worktree | undefined - for (const worktrees of Object.values(selectWorktreesByRepo(state))) { - worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - break - } - } - if (!worktree) { + const owner = findWorktreeReviewOwner(state, worktreeId, executionHostId) + if (!owner) { return } - - const repo = state.repos.find((r) => r.id === worktree.repoId) - if (!repo) { + const { worktree, repo } = owner + const candidate = buildPRRefreshCandidate(state, worktree, undefined, repo) + if (!candidate) { return } + const ownerHostId = getRepoExecutionHostId(candidate) - const branch = worktree.branch.replace(/^refs\/heads\//, '') + const branch = candidate.branch const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const prKey = prCacheKey( - repo.path, - repo.id, - branch, - ownerSettings, - repo.connectionId, - repo.executionHostId - ) + const prKey = candidate.cacheKey const issueKey = worktree.linkedIssue ? issueCacheKey( repo.path, @@ -129,7 +120,7 @@ export const createRefreshSweepActions = ( }) if (!worktree.isBare && branch) { - get().enqueueGitHubPRRefresh(worktreeId, 'post-push', 100) + get().enqueueGitHubPRRefresh(worktreeId, 'post-push', 100, ownerHostId) } if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) { void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) diff --git a/src/renderer/src/store/github/repository-routing.ts b/src/renderer/src/store/github/repository-routing.ts index 9effa6875de..bf1ba606767 100644 --- a/src/renderer/src/store/github/repository-routing.ts +++ b/src/renderer/src/store/github/repository-routing.ts @@ -7,6 +7,7 @@ import type { import type { Repo } from '../../../../shared/repo-types' import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { hasUniqueIndexedRepositoryHost } from '@/lib/repository-owner-index' import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' import { LOCAL_EXECUTION_HOST_ID, @@ -101,9 +102,24 @@ export function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): str export function findRepoForGitHubOwner( state: Partial>, repoId: string | undefined, - repoPath: string + repoPath: string, + executionHostId?: string ): Repo | undefined { - return state.repos - ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) - : undefined + const lookup = getGitHubRepoLookupIndex(state.repos) + if (!executionHostId) { + return lookup.findByIdOrPath(repoId, repoPath) + } + const ownerHostId = normalizeExecutionHostId(executionHostId) + const repo = ownerHostId ? lookup.findOnHost(ownerHostId, repoId, repoPath) : undefined + if ( + !ownerHostId || + !repo || + (repoId && repo.id !== repoId) || + !hasUniqueIndexedRepositoryHost(state.repos, repo.id, ownerHostId) + ) { + throw new Error( + 'The repository is no longer available on its owning host. Reconnect and retry.' + ) + } + return repo } diff --git a/src/renderer/src/store/github/request-coordination.ts b/src/renderer/src/store/github/request-coordination.ts index 229a634618f..d247c4ae8ef 100644 --- a/src/renderer/src/store/github/request-coordination.ts +++ b/src/renderer/src/store/github/request-coordination.ts @@ -3,6 +3,7 @@ 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 { LinkedReviewClearScope } from './linked-review-clear' import type { AppState } from '../types' import { matchesRepoCacheEviction, type RepoCacheEviction } from './cache-identity' @@ -51,7 +52,11 @@ export const inflightProjectViewRequests = new Map( export const prRequestGenerations = new Map() export const prRefreshStartedHostedReviewEntries = new Map< string, - AppState['hostedReviewCache'][string] | undefined + AppState['hostedReviewCache'][string] +>() +export const prRefreshStartedLinkedReviewScopes = new Map< + string, + { sequence: number; scope: LinkedReviewClearScope; expiresAt: number } >() let providerRequestSequence = 0 @@ -81,6 +86,7 @@ export function _getGitHubPRRefreshStartedEntryCountForTest(): number { export function _clearGitHubPRRefreshStartedEntriesForTest(): void { prRefreshStartedHostedReviewEntries.clear() + prRefreshStartedLinkedReviewScopes.clear() } const PROVIDER_REQUEST_CONCURRENCY = 8 diff --git a/src/renderer/src/store/github/review-thread-actions.ts b/src/renderer/src/store/github/review-thread-actions.ts index 3e3a353954a..b0163424430 100644 --- a/src/renderer/src/store/github/review-thread-actions.ts +++ b/src/renderer/src/store/github/review-thread-actions.ts @@ -1,6 +1,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' +import { findRepoForGitHubOwner } from './repository-routing' +import { hostedReviewOwnerIpcArgs } from '../slices/hosted-review-cache-state' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { markPRCommentThreadResolved, @@ -22,104 +24,112 @@ export const createReviewThreadActions = ( get: Parameters>[1] ): Pick => ({ resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - - const inFlightKey = reviewThreadInFlightKey(cacheKey, threadId) - // Why: overlapping opposite requests can settle in either order; true keeps callers from toasting or rolling back. - if (get().reviewThreadResolutionsInFlight[inFlightKey] !== undefined) { - return true - } - set((s) => withReviewThreadInFlight(s, inFlightKey, threadId)) try { - const previousThreadComments = - get().commentsCache[cacheKey]?.data?.filter((comment) => comment.threadId === threadId) ?? - [] - if (previousThreadComments.length > 0) { - set((s) => { - const entry = s.commentsCache[cacheKey] - if (!entry?.data) { - return s - } - return { - commentsCache: { - ...s.commentsCache, - [cacheKey]: { - ...entry, - data: markPRCommentThreadResolved(entry.data, threadId, resolve) + const repo = findRepoForGitHubOwner( + get(), + options?.repoId, + repoPath, + options?.repoOwnerExecutionHostId + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + + const inFlightKey = reviewThreadInFlightKey(cacheKey, threadId) + // Why: overlapping opposite requests can settle in either order; true keeps callers from toasting or rolling back. + if (get().reviewThreadResolutionsInFlight[inFlightKey] !== undefined) { + return true + } + set((s) => withReviewThreadInFlight(s, inFlightKey, threadId)) + try { + const previousThreadComments = + get().commentsCache[cacheKey]?.data?.filter((comment) => comment.threadId === threadId) ?? + [] + if (previousThreadComments.length > 0) { + set((s) => { + const entry = s.commentsCache[cacheKey] + if (!entry?.data) { + return s + } + return { + commentsCache: { + ...s.commentsCache, + [cacheKey]: { + ...entry, + data: markPRCommentThreadResolved(entry.data, threadId, resolve) + } } } - } - }) - } + }) + } - const requestContext = getGitHubWorkItemRequestContext( - get(), - getGitHubSourceTarget(get(), repo, options?.sourceContext), - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false - try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.resolveReviewThread', - { - repo: requestContext.target.runtimeRepoId, + let ok = false + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + getGitHubSourceTarget(get(), repo, options?.sourceContext), + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.resolveReviewThread', + { + repo: requestContext.target.runtimeRepoId, + threadId, + resolve, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ + repoPath, + repoId, threadId, resolve, - prRepo: options?.prRepo ?? null - }, - { 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 && previousThreadComments.length > 0) { - set((s) => { - const entry = s.commentsCache[cacheKey] - if (!entry?.data) { - return s - } - return { - commentsCache: { - ...s.commentsCache, - [cacheKey]: { - ...entry, - data: restorePRCommentThreadSnapshot(entry.data, previousThreadComments) + prRepo: options?.prRepo ?? null, + ...hostedReviewOwnerIpcArgs(options), + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update review thread:', err) + ok = false + } + if (!ok && previousThreadComments.length > 0) { + set((s) => { + const entry = s.commentsCache[cacheKey] + if (!entry?.data) { + return s + } + return { + commentsCache: { + ...s.commentsCache, + [cacheKey]: { + ...entry, + data: restorePRCommentThreadSnapshot(entry.data, previousThreadComments) + } } } - } - }) + }) + } + return ok + } finally { + set((s) => withReviewThreadInFlight(s, inFlightKey, null)) } - return ok - } finally { - set((s) => withReviewThreadInFlight(s, inFlightKey, null)) + } catch { + return false } } }) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 1e5e85abc26..1fd247bf669 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -138,12 +138,13 @@ export type GitHubSlice = { ) => Promise initGitHubCache: () => Promise refreshAllGitHub: () => void - refreshGitHubForWorktree: (worktreeId: string) => void + refreshGitHubForWorktree: (worktreeId: string, executionHostId?: string) => void refreshGitHubForWorktreeIfStale: (worktreeId: string) => void enqueueGitHubPRRefresh: ( worktreeId: string, reason: GitHubPRRefreshReason, - priority?: number + priority?: number, + executionHostId?: string ) => void reportVisibleGitHubPRRefreshCandidates: ( worktreeIds: string[], diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts index 8ff82d55da9..dfd429467d2 100644 --- a/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts @@ -2,7 +2,8 @@ import type { AppState } from '../types' import type { Worktree } from '../../../../shared/worktree/types' import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' -import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { findIndexedRepoOwnerForHost } from '@/lib/worktree-runtime-owner-index' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' export function shouldCoordinateVisibleGitHubReview( state: AppState, @@ -22,7 +23,11 @@ export function shouldCoordinateVisibleGitHubReview( true ) const hosted = state.hostedReviewCache[key] - const remote = getGitHubRepoLookupIndex(state.repos).findById(candidate.repoId)?.gitRemoteIdentity + const remote = findIndexedRepoOwnerForHost( + state.repos, + candidate.repoId, + getRepoExecutionHostId(candidate) + )?.gitRemoteIdentity return ( state.prCache[candidate.cacheKey]?.data != null || hosted?.data?.provider === 'github' || diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts index 2a3afd88beb..5d77986bf42 100644 --- a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts @@ -14,6 +14,7 @@ import { } from '../slices/github-slice-test-harness' import { getVisibleHostedReviewRefreshTargets, + getVisibleHostedReviewWorkspaces, visibleHostedReviewRefreshInputsChanged } from './visible-hosted-review-refresh-targets' import { getWorkspaceReferenceRequest } from '@/components/sidebar/workspace-reference-details' @@ -197,6 +198,298 @@ describe('visible hosted review refresh targets', () => { ) }) + it.each( + (['github', 'gitlab'] as const).flatMap((provider) => [ + { provider, stamped: true }, + { provider, stamped: false } + ]) + )( + 'selects a paired SSH workspace and refreshes $provider through its runtime owner (owner stamped: $stamped)', + async ({ provider, stamped }) => { + const owner: Repo = { + ...repo, + connectionId: 'builder', + executionHostId: 'runtime:owner-server' + } + // Older paired listings named only the physical SSH host. + const paired = { + ...worktree, + hostId: 'ssh:builder' as const, + ...(stamped ? { runtimeOwnerEnvironmentId: 'owner-server' } : {}), + ...(provider === 'github' ? { linkedPR: 12 } : { linkedGitLabMR: 8 }) + } + const store = setup(owner) + store.setState({ + repos: [repo, owner], + worktreesByRepo: { [repo.id]: [{ ...worktree, hostId: 'local' }, paired] }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'ssh:builder', + visibleWorkspaceHostIds: ['ssh:builder'], + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) + const key = `runtime:owner-server::${repo.id}::${worktree.branch}` + const selectedTargets = () => + getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, { + selectedOnly: true + }) + expect(selectedTargets()).toMatchObject([{ key, selected: true }]) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'review', + ok: true, + result: provider === 'github' ? makePR() : review + }) + expect(await selectedTargets()[0].refresh()).toBe(true) + expect(store.getState().hostedReviewCache[key]?.data?.provider).toBe(provider) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-server', + method: provider === 'github' ? 'github.prForBranch' : 'hostedReview.forBranch' + }) + ) + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() + + store.setState({ activeWorkspaceExecutionHostId: 'runtime:owner-server' }) + expect(selectedTargets()).toMatchObject([{ key, selected: true }]) + store.setState({ activeWorkspaceExecutionHostId: 'local' }) + expect(selectedTargets().some((target) => target.key === key)).toBe(false) + + store.setState({ activeWorktreeId: null }) + expect(targets(store)).toMatchObject([{ key, selected: false }]) + store.setState({ visibleWorkspaceHostIds: null, workspaceHostScope: 'ssh:builder' }) + expect(targets(store)).toMatchObject([{ key, selected: false }]) + store.setState({ workspaceHostScope: 'runtime:other-server' }) + expect(targets(store)).toEqual([]) + } + ) + + it.each(['github', 'gitlab'] as const)( + 'refreshes both %s copies when direct SSH and runtime ownership share a physical host', + async (provider) => { + const direct: Repo = { ...repo, connectionId: 'builder' } + const paired: Repo = { ...direct, executionHostId: 'runtime:owner-server' } + const store = setup(direct) + store.setState({ + repos: [paired, direct], + worktreesByRepo: { + [repo.id]: [ + { + ...worktree, + hostId: 'ssh:builder', + runtimeOwnerEnvironmentId: 'owner-server', + ...(provider === 'github' ? { linkedPR: 22 } : { linkedGitLabMR: 9 }) + }, + { + ...worktree, + hostId: 'ssh:builder', + ...(provider === 'github' ? { linkedPR: 12 } : { linkedGitLabMR: 8 }) + } + ] + }, + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) + mockApi.gh.refreshPRNow.mockResolvedValue({ + kind: 'found', + pr: makePR({ number: 12 }), + fetchedAt: Date.now() + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'review', + ok: true, + result: provider === 'github' ? makePR({ number: 22 }) : { ...review, number: 9 } + }) + + const rows = targets(store) + const directKey = `ssh:builder::${repo.id}::${worktree.branch}` + const pairedKey = `runtime:owner-server::${repo.id}::${worktree.branch}` + expect([...getVisibleHostedReviewWorkspaces(store.getState())]).toHaveLength(2) + expect(rows.map((row) => row.key).sort()).toEqual( + provider === 'github' ? [pairedKey] : [pairedKey, directKey].sort() + ) + for (const row of rows) { + expect(await row.refresh()).toBe(true) + } + if (provider === 'github') { + await store.getState().fetchPRForBranch(repo.path, worktree.branch, { + force: true, + repoId: repo.id, + repoOwnerExecutionHostId: 'ssh:builder', + worktreeId: worktree.id, + linkedPRNumber: 12 + }) + } + expect(store.getState().hostedReviewCache[directKey]?.data?.number).toBe( + provider === 'github' ? 12 : 8 + ) + expect(store.getState().hostedReviewCache[pairedKey]?.data?.number).toBe( + provider === 'github' ? 22 : 9 + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-server', + method: provider === 'github' ? 'github.prForBranch' : 'hostedReview.forBranch' + }) + ) + const directTransport = + provider === 'github' ? mockApi.gh.refreshPRNow : mockApi.hostedReview.forBranch + expect(directTransport).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + { activeHost: 'ssh:builder', focusedServer: null, selectedHost: 'ssh:builder' }, + { + activeHost: 'runtime:owner-server', + focusedServer: null, + selectedHost: 'runtime:owner-server' + }, + { activeHost: null, focusedServer: 'owner-server', selectedHost: 'runtime:owner-server' } + ] as const)( + 'gives only the resolved $selectedHost copy foreground polling (active: $activeHost)', + async ({ activeHost, focusedServer, selectedHost }) => { + const direct: Repo = { ...repo, connectionId: 'builder' } + const paired: Repo = { ...direct, executionHostId: 'runtime:owner-server' } + const directKey = `ssh:builder::${repo.id}::${worktree.branch}` + const pairedKey = `runtime:owner-server::${repo.id}::${worktree.branch}` + const selectedKey = `${selectedHost}::${repo.id}::${worktree.branch}` + const store = setup(direct) + store.setState({ + repos: [paired, direct], + worktreesByRepo: { + [repo.id]: [ + { ...worktree, hostId: 'ssh:builder', linkedGitLabMR: 8 }, + { + ...worktree, + hostId: 'ssh:builder', + runtimeOwnerEnvironmentId: 'owner-server', + linkedGitLabMR: 9 + } + ] + }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: activeHost, + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: focusedServer }), + hostedReviewCache: { + [directKey]: { data: review, fetchedAt: Date.now() }, + [pairedKey]: { data: { ...review, number: 9 }, fetchedAt: Date.now() } + }, + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) + + const rows = targets(store) + expect(rows).toHaveLength(2) + expect(rows.filter((row) => row.selected).map((row) => row.key)).toEqual([selectedKey]) + runtimeEnvironmentCall.mockResolvedValue({ id: 'review', ok: true, result: review }) + for (const row of rows) { + expect(row.intervalMs).toBe(row.key === selectedKey ? 60_000 : 120_000) + await row.refresh() + } + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ + admissionTier: selectedHost === 'ssh:builder' ? 'interactive' : 'background' + }) + ) + expect(mockApi.hostedReview.forBranch.mock.lastCall?.[0]).toEqual( + selectedHost === 'ssh:builder' + ? expect.objectContaining({ active: true }) + : expect.not.objectContaining({ active: true }) + ) + expect( + getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, { + selectedOnly: true + }).map((row) => row.key) + ).toEqual([selectedKey]) + store.setState({ visibleWorkspaceHostIds: ['local'] }) + expect(targets(store).map((row) => row.key)).toEqual([selectedKey]) + store.setState({ visibleWorkspaceHostIds: [selectedHost] }) + expect(targets(store).map((row) => row.key)).toEqual( + selectedHost === 'ssh:builder' ? [directKey, pairedKey] : [pairedKey] + ) + } + ) + + it.each(['ssh:builder', 'runtime:owner-server'] as const)( + 'rejects duplicate explicit %s worktree owners while retaining the other copy', + (hostId) => { + const direct: Repo = { ...repo, connectionId: 'builder' } + const store = setup(direct) + const directWorktree = { ...worktree, hostId: 'ssh:builder' as const, linkedGitLabMR: 8 } + const pairedWorktree = { ...directWorktree, runtimeOwnerEnvironmentId: 'owner-server' } + store.setState({ + repos: [direct, { ...direct, executionHostId: 'runtime:owner-server' }], + worktreesByRepo: { + [repo.id]: [ + directWorktree, + pairedWorktree, + { ...(hostId === 'ssh:builder' ? directWorktree : pairedWorktree) } + ] + }, + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) + const otherHostId = hostId === 'ssh:builder' ? 'runtime:owner-server' : 'ssh:builder' + expect(targets(store).map((row) => row.key)).toEqual([ + `${otherHostId}::${repo.id}::${worktree.branch}` + ]) + } + ) + + it('rejects an older paired SSH workspace that rival servers both proxy', () => { + const first: Repo = { ...repo, connectionId: 'builder', executionHostId: 'runtime:first' } + const store = setup(first) + store.setState({ + repos: [first, { ...first, executionHostId: 'runtime:second' }], + worktreesByRepo: { [repo.id]: [{ ...worktree, hostId: 'ssh:builder', linkedPR: 12 }] }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'ssh:builder', + sshConnectionStates: new Map([ + ['builder', { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + }) + expect(targets(store)).toEqual([]) + store.getState().refreshGitHubForWorktree(worktree.id, 'runtime:first') + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('keeps an older SSH workspace on its direct repo when a server also proxies it', async () => { + const direct: Repo = { ...repo, connectionId: 'builder' } + const store = setup(direct) + store.setState({ + repos: [{ ...direct, executionHostId: 'runtime:owner-server' }, direct], + worktreesByRepo: { [repo.id]: [{ ...worktree, hostId: 'ssh:builder' }] }, + sshConnectionStates: new Map([ + ['builder', { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + }) + const rows = targets(store) + expect(rows.map((row) => row.key)).toEqual([`ssh:builder::${repo.id}::${worktree.branch}`]) + await rows[0].refresh() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ repoOwnerExecutionHostId: 'ssh:builder' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('excludes folders, bare, archived, detached, missing and disconnected SSH workspaces', () => { const store = setup() store.setState({ repos: [{ ...repo, kind: 'folder' }] }) @@ -457,6 +750,28 @@ describe('visible hosted review refresh targets', () => { ).toEqual([]) }) + it('retains selection for a workspace published only in the detected catalog', () => { + const store = setup() + store.setState({ + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'local', + worktreesByRepo: {}, + detectedWorktreesByRepo: { + [repo.id]: { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: [{ ...worktree, ownership: 'external', selectedCheckout: true, visible: true }] + } + } + }) + expect( + [...getVisibleHostedReviewWorkspaces(store.getState(), { selectedOnly: true })].map( + ({ worktree, selected }) => ({ id: worktree.id, selected }) + ) + ).toEqual([{ id: worktree.id, selected: true }]) + }) + it.each>([ { activeWorkspaceExecutionHostId: 'ssh:other' }, { visibleWorkspaceHostIds: ['local'] }, @@ -468,7 +783,7 @@ describe('visible hosted review refresh targets', () => { expect(visibleHostedReviewRefreshInputsChanged(next, previous)).toBe(true) }) - it('does not send a colliding runtime owner through the legacy unqualified GitHub action', () => { + it('refreshes the selected runtime copy when repository and worktree IDs collide', async () => { const first: Repo = { ...repo, executionHostId: 'runtime:first', @@ -489,11 +804,23 @@ describe('visible hosted review refresh targets', () => { second: [{ ...worktree, hostId: 'runtime:second' }] } }) - expect( - getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, { selectedOnly: true }) - ).toEqual([]) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'pr-second', + ok: true, + result: makePR({ number: 26 }), + _meta: { runtimeId: 'second' } + }) + const selected = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, { + selectedOnly: true + }) + expect(selected).toHaveLength(1) + expect(await selected[0].refresh()).toBe(true) + expect(store.getState().prCache[`runtime:second::${prKey}`]?.data?.number).toBe(26) + expect(store.getState().prCache[`runtime:first::${prKey}`]).toBeUndefined() expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'second' }) + ) }) it('lets normal hosted-review refreshes share the host cache but forces discovery', async () => { diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts index e6395e73680..94c4694b5fd 100644 --- a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts @@ -12,13 +12,16 @@ import { import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy' import { buildPRRefreshCandidate, + findWorktreeReviewOwner, getWorktreeLookupIndex, findWorktreeById } from './worktree-refresh' -import { findIndexedDetectedWorktrees } from '@/lib/worktree-runtime-owner-index' +import { + findIndexedDetectedWorktrees, + findIndexedRepoOwnerForHost, + worktreeOwnerHostIds +} from '@/lib/worktree-runtime-owner-index' import { findKnownWorktreeById } from '../slices/worktrees/listing/detected-worktree-meta' -import { findRepoForHost } from '../slices/repo-host-identity' -import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' import { getPRRefreshRuntimeRepoTarget } from './repository-routing' import type { VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler' import { shouldCoordinateVisibleGitHubReview } from './visible-hosted-review-refresh-ownership' @@ -27,33 +30,50 @@ import { isWslHostOffForForge } from './wsl-host-off-forge' export function* getVisibleHostedReviewWorkspaces( state: AppState, - options?: { selectedOnly?: boolean } + options?: { selectedOnly?: boolean; worktreeIds?: readonly string[] } ) { const visibleHosts = state.visibleWorkspaceHostIds ?? (state.workspaceHostScope && state.workspaceHostScope !== 'all' ? [state.workspaceHostScope] : null) - for (const id of state.visibleReviewWorktreeIds) { - const owners = new Set(getWorktreeLookupIndex(state).byId.get(id)?.owners) + for (const id of options?.worktreeIds ?? state.visibleReviewWorktreeIds) { + const lookupIndex = getWorktreeLookupIndex(state) + const trackedOwners = lookupIndex.byId.get(id)?.owners + const owners = new Set(trackedOwners) for (const detected of findIndexedDetectedWorktrees(selectDetectedWorktreesByRepo(state), id)) { const workspace = findKnownWorktreeById(state, id, detected.hostId ?? 'local') if (workspace) { owners.add(workspace) } } + const selectedOwner = + id === state.activeWorktreeId + ? findWorktreeReviewOwner( + state, + id, + state.activeWorkspaceExecutionHostId ?? + (owners.size === 1 ? undefined : getSettingsFocusedExecutionHostId(state.settings)), + owners.size === (trackedOwners?.length ?? 0) + ? lookupIndex + : { + ...lookupIndex, + byId: new Map([ + [id, { owners: [...owners], unique: owners.size === 1 ? [...owners][0] : null }] + ]) + } + ) + : null for (const worktree of owners) { if (worktree.isArchived || worktree.isBare) { continue } - const repo = findRepoForHost(state.repos, worktree.repoId, { - hostId: worktree.hostId, - settings: state.settings - }) - if (!repo) { - continue - } - const candidate = buildPRRefreshCandidate(state, worktree, undefined, repo) + const candidate = buildPRRefreshCandidate( + state, + worktree, + undefined, + selectedOwner?.worktree === worktree ? selectedOwner.repo : undefined + ) if ( !candidate || candidate.repoKind !== 'git' || @@ -61,7 +81,11 @@ export function* getVisibleHostedReviewWorkspaces( ) { continue } - const hostId = getRepoExecutionHostId(repo) + const hostId = getRepoExecutionHostId(candidate) + const repo = findIndexedRepoOwnerForHost(state.repos, worktree.repoId, hostId) + if (!repo) { + continue + } const runtimeHost = parseExecutionHostId(hostId) if ( runtimeHost?.kind === 'runtime' && @@ -69,19 +93,20 @@ export function* getVisibleHostedReviewWorkspaces( ) { continue } - const activeHost = state.activeWorkspaceExecutionHostId + const hostIds = + worktree.hostId || worktree.runtimeOwnerEnvironmentId + ? [...new Set([...worktreeOwnerHostIds(worktree), hostId])] + : [hostId] const selected = - id === state.activeWorktreeId && - (activeHost - ? hostId === activeHost - : owners.size === 1 || hostId === getSettingsFocusedExecutionHostId(state.settings)) + selectedOwner?.worktree === worktree && + getRepoExecutionHostId(selectedOwner.repo) === hostId if ( (options?.selectedOnly && !selected) || - (!selected && visibleHosts && !visibleHosts.includes(hostId)) + (!selected && visibleHosts && !hostIds.some((hostId) => visibleHosts.includes(hostId))) ) { continue } - yield { worktree, repo, candidate, selected } + yield { worktree, repo, candidate: { ...candidate, isSelected: selected }, selected } } } } @@ -128,11 +153,9 @@ export function getVisibleHostedReviewRefreshTargets( hints.linkedAzureDevOpsPR != null || hints.linkedGiteaPR != null || (hostedEntry?.data != null && hostedEntry.data.provider !== 'github') - // The legacy GitHub action still resolves bare IDs; never pass it another owner's candidate. if ( !exactRequest && - (getGitHubRepoLookupIndex(state.repos).findById(repo.id) !== repo || - findWorktreeById(state, worktree.id) !== worktree) + findWorktreeById(state, worktree.id, getRepoExecutionHostId(repo)) !== worktree ) { continue } @@ -175,6 +198,7 @@ export function getVisibleHostedReviewRefreshTargets( force: true, reason: 'visible', repoId: candidate.repoId, + repoOwnerExecutionHostId: getRepoExecutionHostId(candidate), worktreeId: id, linkedPRNumber: candidate.linkedPRNumber, fallbackPRNumber: candidate.fallbackPRNumber, diff --git a/src/renderer/src/store/github/worktree-refresh.ts b/src/renderer/src/store/github/worktree-refresh.ts index 92661af1e33..7143e0c0ab6 100644 --- a/src/renderer/src/store/github/worktree-refresh.ts +++ b/src/renderer/src/store/github/worktree-refresh.ts @@ -5,23 +5,122 @@ import type { Worktree } from '../../../../shared/worktree/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId + normalizeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' -import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { resolveReviewRepository, reviewRepositoryMatchesWorkspace } from '@/lib/review-owner' +import { + findIndexedReviewRepository, + hasUniqueIndexedRepositoryHost +} from '@/lib/repository-owner-index' +import { worktreeOwnerHostIds } from '@/lib/worktree-runtime-owner-index' import { prCacheKey } from './cache-identity' import { settingsForGitHubRepoOwner } from './work-item-routing' import { githubHostedReviewFallbackPRNumber } from './pr-result-routing' import type { GitHubPRFallbackSource } from './cache-model' import { selectWorktreesByRepo } from '../workspace-record-selectors' -export function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null +export function findWorktreeById( + state: AppState, + worktreeId: string, + executionHostId?: string, + lookupIndex = getWorktreeLookupIndex(state) +): Worktree | null { + const entry = lookupIndex.byId.get(worktreeId) + if (!entry) { + return null + } + const requestedHostId = + executionHostId ?? + (state.activeWorktreeId === worktreeId ? state.activeWorkspaceExecutionHostId : null) + if (!requestedHostId) { + return entry.unique + } + const hostId = normalizeExecutionHostId(requestedHostId) + if (!hostId) { + return null + } + const explicitMatches = entry.owners.filter((worktree) => { + const runtimeOwner = worktree.runtimeOwnerEnvironmentId?.trim() + const ownerHostId = runtimeOwner + ? toRuntimeExecutionHostId(runtimeOwner) + : normalizeExecutionHostId(worktree.hostId) + return ownerHostId === hostId + }) + // Explicit execution owners take precedence over physical aliases and legacy recovery. + if (explicitMatches.length > 0) { + return explicitMatches.length === 1 ? explicitMatches[0] : null + } + const matches = entry.owners.filter((worktree) => { + if (worktree.hostId || worktree.runtimeOwnerEnvironmentId) { + return ( + worktreeOwnerHostIds(worktree).includes(hostId) || + matchesLegacyReviewRepository(state, worktree, hostId) + ) + } + const repoHosts = lookupIndex.repoHostIdsByRepoId.get(worktree.repoId) + // Legacy rows inherit a sole repo owner, or remain local when that repo has host twins. + return repoHosts?.size === 1 + ? repoHosts.has(hostId) + : hostId === LOCAL_EXECUTION_HOST_ID && (!repoHosts || repoHosts.has(hostId)) + }) + return matches.length === 1 ? matches[0] : null +} + +export function findWorktreeReviewOwner( + state: AppState, + worktreeId: string, + executionHostId?: string, + lookupIndex = getWorktreeLookupIndex(state) +): { worktree: Worktree; repo: Repo } | null { + const worktree = findWorktreeById(state, worktreeId, executionHostId, lookupIndex) + if (!worktree) { + return null + } + const requestedHost = normalizeExecutionHostId( + executionHostId ?? + (state.activeWorktreeId === worktreeId ? state.activeWorkspaceExecutionHostId : null) + ) + const runtimeOwner = worktree.runtimeOwnerEnvironmentId?.trim() + const repositoryHost = runtimeOwner ? toRuntimeExecutionHostId(runtimeOwner) : requestedHost + const selectedRepo = repositoryHost + ? findIndexedReviewRepository( + state.repos, + worktree.repoId, + repositoryHost, + parseExecutionHostId(repositoryHost)?.kind === 'runtime' && + parseExecutionHostId(worktree.hostId)?.kind === 'ssh' + ? worktree.hostId + : undefined + ) + : undefined + if (repositoryHost && !selectedRepo) { + return null + } + const repo = resolveReviewRepository(state.repos, worktree, selectedRepo ?? undefined) + return repo ? { worktree, repo } : null +} + +function matchesLegacyReviewRepository( + state: AppState, + worktree: Worktree, + hostId: ExecutionHostId +): boolean { + if ( + worktree.runtimeOwnerEnvironmentId?.trim() || + parseExecutionHostId(worktree.hostId)?.kind !== 'ssh' + ) { + return false + } + const repo = findIndexedReviewRepository(state.repos, worktree.repoId, hostId, worktree.hostId) + return Boolean(repo && reviewRepositoryMatchesWorkspace(repo, worktree)) } type WorktreeLookupEntry = { owners: Worktree[] - first: Worktree unique: Worktree | null } @@ -43,7 +142,7 @@ export function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { existing.owners.push(worktree) existing.unique = null } else { - byId.set(worktreeId, { owners: [worktree], first: worktree, unique: worktree }) + byId.set(worktreeId, { owners: [worktree], unique: worktree }) } } } @@ -106,14 +205,13 @@ export function isStaleExactLinkedPRLookup( state: AppState, worktreeId: string | undefined, linkedPRNumber: number | null | undefined, + executionHostId?: string, lookupIndex?: WorktreeLookupIndex ): boolean { if (!worktreeId || linkedPRNumber == null) { return false } - const worktree = lookupIndex - ? (lookupIndex.byId.get(worktreeId)?.first ?? null) - : findWorktreeById(state, worktreeId) + const worktree = findWorktreeById(state, worktreeId, executionHostId, lookupIndex) return worktree?.linkedPR !== linkedPRNumber } @@ -123,8 +221,11 @@ export function buildPRRefreshCandidate( repoPath?: string, repoOverride?: Repo ): GitHubPRRefreshCandidate | null { - const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) - if (!repo) { + const repo = resolveReviewRepository(state.repos, worktree, repoOverride) + if ( + !repo || + !hasUniqueIndexedRepositoryHost(state.repos, repo.id, getRepoExecutionHostId(repo)) + ) { return null } if (isMacAppDataPath(repoPath ?? repo.path)) { diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 4be4d5af415..4dfaacc6491 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -14,6 +14,7 @@ import { resetFloatingWorkspaceUnreadSelectorCacheForTest, selectKnownWorktreeById, selectRepoByIdForActiveWorkspace, + selectReviewRepositoryForWorkspace, selectFloatingVisibleTabCount, selectFloatingWorkspaceHasUnread } from './selectors' @@ -68,6 +69,46 @@ describe('store selectors', () => { resetFloatingVisibleTabCountSelectorCacheForTest() }) + it('keeps review reads on the workspace owner when direct SSH and server copies overlap', () => { + const direct = makeRepo({ + id: 'repo', + path: '/direct', + displayName: 'Direct', + executionHostId: 'ssh:builder' + }) + const server = { + ...direct, + path: '/server', + executionHostId: 'runtime:server' as const, + connectionId: 'builder' + } + const workspace = { + repoId: direct.id, + hostId: 'ssh:builder' as const, + runtimeOwnerEnvironmentId: 'server' + } + const state = { + repos: [direct, server], + activeRepoId: direct.id, + activeWorkspaceExecutionHostId: 'ssh:builder' as const + } + + expect(selectReviewRepositoryForWorkspace(state, workspace)).toBe(server) + expect(selectReviewRepositoryForWorkspace({ ...state, repos: [direct] }, workspace)).toBeNull() + expect( + selectReviewRepositoryForWorkspace(state, { + ...workspace, + runtimeOwnerEnvironmentId: undefined + }) + ).toBe(direct) + expect( + selectReviewRepositoryForWorkspace( + { ...state, activeWorkspaceExecutionHostId: 'runtime:server' }, + { ...workspace, runtimeOwnerEnvironmentId: undefined } + ) + ).toBe(server) + }) + it('deduplicates cached worktree snapshots without changing reference reuse', () => { const first = makeWorktree({ id: 'wt-1', repoId: 'repo-1', displayName: 'first' }) const second = makeWorktree({ id: 'wt-2', repoId: 'repo-1', displayName: 'second' }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index d0e240dea87..a342a4c5ec9 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -21,6 +21,7 @@ import { findKnownWorktreeById } from './slices/worktrees/listing/detected-workt import { selectWorktreesByRepo } from './workspace-record-selectors' import { worktreeCopyOwnerKey } from './slices/worktrees/metadata/worktree-copy-review-clear' import { findSoleHostCopy } from '../../../shared/worktree/host-qualified-identity' +import { resolveReviewRepository, reviewRepositoryMatchesWorkspace } from '@/lib/review-owner' export { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' export { @@ -207,6 +208,23 @@ export function selectRepoByIdForActiveWorkspace( export const useRepoById = (repoId: string | null) => useAppStore((s) => selectRepoByIdForActiveWorkspace(s, repoId)) + +export function selectReviewRepositoryForWorkspace( + state: ActiveWorkspaceRepoState, + workspace: Pick | null +): Repo | null { + if (!workspace) { + return null + } + const selectedRepo = selectRepoByIdForActiveWorkspace(state, workspace.repoId) + return resolveReviewRepository( + state.repos, + workspace, + selectedRepo && reviewRepositoryMatchesWorkspace(selectedRepo, workspace) + ? selectedRepo + : undefined + ) +} export const useProjectHostSetupProjection = () => useAppStore((s) => getProjectHostSetupProjectionFromState(s)) diff --git a/src/renderer/src/store/slices/editor-check-details-reconstructed-owner.test.ts b/src/renderer/src/store/slices/editor-check-details-reconstructed-owner.test.ts new file mode 100644 index 00000000000..cd69d70cd81 --- /dev/null +++ b/src/renderer/src/store/slices/editor-check-details-reconstructed-owner.test.ts @@ -0,0 +1,257 @@ +import { expect, it, vi } from 'vitest' +import { createCheckRunDetailsRequestId } from '@/components/editor/check-run-details-tab' +import { createEditorTabsStore } from './editor-slice-test-harness' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' +import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { publishedLayoutsOf } from '@/runtime/main-loaded-layout-frames.test-fixture' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { buildLayoutFrameSlices } from '../workspace-layout-frame-slices' +import { loadWorkspaceLayoutWithoutReport } from '../../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../../shared/workspace-layout/workspace-layout-minted-ids' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN).each(['local', 'ssh:builder'] as const)( + 'refreshes reconstructed %s checks and isolates colliding requests from its runtime twin', + async (hostId) => { + const repo = { + ...TEST_REPO, + id: 'repo-1', + path: '/owner', + executionHostId: hostId, + connectionId: hostId === 'local' ? null : 'builder' + } + const workspace = makeWorktree({ id: 'wt-1', repoId: repo.id, path: repo.path, hostId }) + const catalog = { + repos: [ + repo, + { ...repo, path: '/rival', executionHostId: 'runtime:server' as const, connectionId: null } + ], + worktreesByRepo: { + [repo.id]: [ + workspace, + { + ...workspace, + path: '/rival', + hostId: 'runtime:server' as const, + runtimeOwnerEnvironmentId: 'server' + } + ] + } + } + const source = createEditorTabsStore() + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + source.setState({ ...catalog, activeWorkspaceExecutionHostId: hostId, fetchPRCheckDetails }) + const sourceFrames = await drawTabsFromMain(source) + const model = layoutApplyTestTransport.mainModel! + layoutApplyTestTransport.useMainModel({ ...model, hostId }) + source.getState().openCheckRunDetails( + workspace.id, + 'repo:99', + { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: null, + checkRunId: 42 + }, + { requestId: createCheckRunDetailsRequestId(), details: null, loading: false, error: null } + ) + await sourceFrames.settle() + const fileId = source.getState().openFiles[0]!.id + await source.getState().reloadOpenCheckRunDetailsTab(fileId) + expect(fetchPRCheckDetails).toHaveBeenCalledWith('/owner', expect.anything(), { + repoId: repo.id, + repoOwnerExecutionHostId: hostId + }) + const layouts = publishedLayoutsOf(layoutApplyTestTransport.mainModel!) + expect(layouts[workspace.id]!.tabs[0]!.executionHostId).toBe(hostId) + const { facts, desktopView } = loadWorkspaceLayoutWithoutReport( + hostId, + buildWorkspaceSessionPayload(source.getState()), + nameBasedLoadContext() + ) + try { + const restored = createEditorTabsStore() + restored.setState({ + ...buildLayoutFrameSlices({ layouts, facts, view: desktopView }), + ...catalog, + activeWorkspaceExecutionHostId: 'runtime:server', + fetchPRCheckDetails + }) + expect(restored.getState().openFiles).toHaveLength(1) + const previous = restored.getState().openFiles[0]!.checkRunDetails! + restored.getState().openCheckRunDetails(workspace.id, previous.contextKey, previous.check, { + requestId: previous.requestId, + details: null, + loading: true, + error: null + }) + expect(previous.requestId).toEqual(expect.any(Number)) + const runtimeTabId = restored.getState().activeFileId + expect(runtimeTabId).not.toBe(fileId) + restored.setState({ activeWorkspaceExecutionHostId: hostId }) + restored + .getState() + .patchOpenCheckRunDetails(workspace.id, previous.contextKey, previous.check, { + requestId: previous.requestId, + details: null, + loading: false, + error: 'Runtime request completed' + }) + expect( + restored.getState().openFiles.find((file) => file.id === runtimeTabId)?.checkRunDetails + ).toMatchObject({ loading: false, error: 'Runtime request completed' }) + expect( + restored.getState().openFiles.find((file) => file.id === fileId)?.checkRunDetails + ).toEqual(previous) + + fetchPRCheckDetails.mockClear() + await restored.getState().reloadOpenCheckRunDetailsTab(fileId) + expect(fetchPRCheckDetails).toHaveBeenCalledWith('/owner', expect.anything(), { + repoId: repo.id, + repoOwnerExecutionHostId: hostId + }) + restored.setState({ + repos: [catalog.repos[1]!], + worktreesByRepo: { [repo.id]: [catalog.worktreesByRepo[repo.id]![1]!] } + }) + fetchPRCheckDetails.mockClear() + await restored.getState().reloadOpenCheckRunDetailsTab(fileId) + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(restored.getState().openFiles[0]!.checkRunDetails?.error).toBe( + 'Repository details are unavailable for this check.' + ) + } finally { + sourceFrames.close() + } + } +) + +it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN).each([ + { hostId: 'local', runtimeEnvironmentId: undefined, scope: '' }, + { hostId: 'runtime:server', runtimeEnvironmentId: 'server', scope: '@runtime:server' }, + { hostId: 'ssh:builder', runtimeEnvironmentId: 'server', scope: '@ssh:builder>runtime:server' } +] satisfies { hostId: ExecutionHostId; runtimeEnvironmentId: string | undefined; scope: string }[])( + 'reuses a legacy $hostId check tab after layout reconstruction', + ({ hostId, runtimeEnvironmentId, scope }) => { + const worktreeId = 'wt-1' + const legacyId = `${worktreeId}::check-details::check-run:42` + const expectedId = `${worktreeId}::check-details${scope}::check-run:42` + const check = { + name: 'verify', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + checkRunId: 42 + } + const previous = { + contextKey: 'repo:99', + check, + requestId: 10, + details: null, + loading: false, + error: 'Restored error' + } + const session = { + ...getDefaultWorkspaceSession(), + unifiedTabs: { + [worktreeId]: [ + { + id: legacyId, + entityId: legacyId, + worktreeId, + groupId: 'g1', + contentType: 'check-details' as const, + label: 'verify', + customLabel: null, + color: null, + createdAt: 1, + sortOrder: 0, + executionHostId: hostId + } + ] + }, + tabGroups: { + [worktreeId]: [ + { + id: 'g1', + worktreeId, + tabOrder: [legacyId], + activeTabId: legacyId, + recentTabIds: [legacyId] + } + ] + } + } + const loaded = loadWorkspaceLayoutWithoutReport(hostId, session, { + ...nameBasedLoadContext(), + editorViewsOf: () => ({ + [legacyId]: { + view: { mode: 'check-details', checkRunDetails: previous }, + file: { + filePath: legacyId, + relativePath: 'verify', + language: 'plaintext', + runtimeEnvironmentId + } + } + }) + }) + const layouts = publishedLayoutsOf(loaded.layout) + const store = createEditorTabsStore() + const repo = { + ...TEST_REPO, + id: 'repo-1', + executionHostId: runtimeEnvironmentId ? ('runtime:server' as const) : hostId, + connectionId: hostId === 'ssh:builder' ? 'builder' : null + } + store.setState({ + ...buildLayoutFrameSlices({ layouts, facts: loaded.facts, view: loaded.desktopView }), + repos: [repo], + worktreesByRepo: { + [repo.id]: [ + makeWorktree({ + id: worktreeId, + repoId: repo.id, + hostId, + runtimeOwnerEnvironmentId: runtimeEnvironmentId + }) + ] + }, + activeWorkspaceExecutionHostId: hostId + }) + expect(store.getState().openFiles).toMatchObject([ + { id: expectedId, filePath: expectedId, checkRunDetails: previous } + ]) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toMatchObject([ + { id: legacyId, entityId: expectedId } + ]) + expect(store.getState().groupsByWorktree[worktreeId]).toMatchObject([ + { tabOrder: [legacyId], activeTabId: legacyId, recentTabIds: [legacyId] } + ]) + store.getState().openCheckRunDetails(worktreeId, previous.contextKey, check, { + requestId: 9, + details: null, + loading: true, + error: null + }) + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]!.checkRunDetails).toEqual(previous) + expect(store.getState().activeFileId).toBe(expectedId) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toHaveLength(1) + } +) diff --git a/src/renderer/src/store/slices/editor-check-details-tab-owner.test.ts b/src/renderer/src/store/slices/editor-check-details-tab-owner.test.ts new file mode 100644 index 00000000000..3c10473c843 --- /dev/null +++ b/src/renderer/src/store/slices/editor-check-details-tab-owner.test.ts @@ -0,0 +1,319 @@ +import type { StoreApi } from 'zustand/vanilla' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEditorTabsStore } from './editor-slice-test-harness' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' +import { createCheckRunDetailsRequestId } from '@/components/editor/check-run-details-tab' +import type { AppState } from '../types' +import type { PRCheckRunDetails } from '../../../../shared/github/check-types' + +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +const check = { + name: 'verify', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + checkRunId: 42 +} +const details: PRCheckRunDetails = { + name: 'verify', + status: 'completed', + conclusion: 'success', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: 'Build passed', + summary: null, + text: null, + annotations: [], + jobs: [] +} +const pairedRepo: AppState['repos'][number] = { + ...TEST_REPO, + id: 'repo-1', + path: '/remote', + executionHostId: 'runtime:server', + connectionId: 'builder' +} +const pairedWorktree = makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '/remote', + hostId: 'ssh:builder', + runtimeOwnerEnvironmentId: 'server' +}) + +function createOwnerStore(state: Partial) { + const fetchPRCheckDetails = vi.fn().mockResolvedValue(details) + const store = createEditorTabsStore() + store.setState({ + activeWorktreeId: 'wt-1', + detectedWorktreesByRepo: {}, + settings: null, + fetchPRCheckDetails, + ...state + }) + const setCatalog = (catalog: Partial) => store.setState(catalog) + return { store, fetchPRCheckDetails, setCatalog } +} + +function open(store: StoreApi, worktreeId = 'wt-1'): string { + store.getState().openCheckRunDetails(worktreeId, 'repo:99', check, { + details: null, + loading: false, + error: null + }) + return store.getState().activeFileId! +} + +describe('check-details tab owner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(['tracked', 'detected'] as const)( + 'reloads a pinned direct SSH tab when its %s listing shares a target with a server proxy', + async (source) => { + const directRepo = { ...pairedRepo, path: '/direct', executionHostId: 'ssh:builder' as const } + const directWorkspace = { + ...pairedWorktree, + id: 'repo-1::/direct', + path: '/direct', + runtimeOwnerEnvironmentId: undefined + } + const listings = ( + rows: AppState['worktreesByRepo'][string] + ): Pick => ({ + worktreesByRepo: source === 'tracked' ? { 'repo-1': rows } : {}, + detectedWorktreesByRepo: + source === 'detected' + ? { + 'repo-1': { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: rows.map((row) => ({ + ...row, + ownership: 'external', + selectedCheckout: false, + visible: true + })) + } + } + : {} + }) + const { store, fetchPRCheckDetails, setCatalog } = createOwnerStore({ + activeWorktreeId: directWorkspace.id, + activeWorkspaceExecutionHostId: 'ssh:builder', + repos: [directRepo, pairedRepo], + ...listings([directWorkspace]) + }) + const tabId = open(store, directWorkspace.id) + expect(store.getState().openFiles[0].operationProvenance?.generation.route).toEqual({ + executionHostId: 'ssh:builder', + runtimeEnvironmentId: null + }) + store.setState({ activeWorkspaceExecutionHostId: 'runtime:server' }) + + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + + expect(fetchPRCheckDetails).toHaveBeenCalledWith( + '/direct', + expect.objectContaining({ checkRunId: 42 }), + { repoId: 'repo-1', repoOwnerExecutionHostId: 'ssh:builder' } + ) + fetchPRCheckDetails.mockClear() + setCatalog({ repos: [pairedRepo] }) + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + + setCatalog({ + repos: [directRepo, pairedRepo], + ...listings([directWorkspace, { ...pairedWorktree, id: directWorkspace.id }]) + }) + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(store.getState().openFiles[0].checkRunDetails?.error).toBe( + 'Repository details are unavailable for this check.' + ) + + const pairedTabId = open(store, directWorkspace.id) + await store.getState().reloadOpenCheckRunDetailsTab(pairedTabId) + expect(fetchPRCheckDetails).toHaveBeenCalledWith( + '/remote', + expect.objectContaining({ checkRunId: 42 }), + { repoId: 'repo-1', repoOwnerExecutionHostId: 'runtime:server' } + ) + } + ) + + it.each([ + { + rival: 'a direct SSH listing', + repo: { ...pairedRepo, path: '/direct', executionHostId: undefined, connectionId: 'builder' }, + worktree: { ...pairedWorktree, path: '/direct', runtimeOwnerEnvironmentId: undefined } + }, + { + rival: 'another server', + repo: { ...pairedRepo, path: '/other', executionHostId: 'runtime:other' }, + worktree: { ...pairedWorktree, path: '/other', runtimeOwnerEnvironmentId: 'other' } + } + ] as const)( + 'refuses to reload a paired SSH tab through $rival once its owner is gone', + async ({ repo, worktree }) => { + const { store, fetchPRCheckDetails, setCatalog } = createOwnerStore({ + repos: [pairedRepo], + worktreesByRepo: { 'repo-1': [pairedWorktree] } + }) + const tabId = open(store) + setCatalog({ repos: [repo], worktreesByRepo: { 'repo-1': [worktree] } }) + + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(store.getState().openFiles[0].checkRunDetails).toMatchObject({ + loading: false, + error: 'Repository details are unavailable for this check.' + }) + } + ) + + it.each([ + { openedThrough: 'its server host', activeHost: 'runtime:server' }, + { openedThrough: 'the catalog', activeHost: undefined } + ] as const)( + 'reloads a legacy paired SSH workspace opened through $openedThrough', + async ({ activeHost }) => { + const { store, fetchPRCheckDetails, setCatalog } = createOwnerStore({ + activeWorkspaceExecutionHostId: activeHost, + repos: [pairedRepo], + // Older paired listings stamp only the SSH machine; the server owner comes from the repo. + worktreesByRepo: { + 'repo-1': [{ ...pairedWorktree, runtimeOwnerEnvironmentId: undefined }] + } + }) + const tabId = open(store) + setCatalog({ + repos: [pairedRepo, { ...pairedRepo, path: '/direct', executionHostId: 'ssh:builder' }], + activeWorkspaceExecutionHostId: 'ssh:builder' + }) + + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + + expect(fetchPRCheckDetails).toHaveBeenCalledWith( + '/remote', + expect.objectContaining({ checkRunId: 42 }), + { repoId: 'repo-1', repoOwnerExecutionHostId: 'runtime:server' } + ) + fetchPRCheckDetails.mockClear() + setCatalog({ repos: [{ ...pairedRepo, path: '/direct', executionHostId: 'ssh:builder' }] }) + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect( + store.getState().openFiles.find((file) => file.id === tabId)?.checkRunDetails?.error + ).toBe('Repository details are unavailable for this check.') + } + ) + + it('refuses to reload an unpinned tab when a detected checkout rivals the tracked row', async () => { + const localRepo = { ...TEST_REPO, id: 'repo-1', path: '/repo' } + const { store, fetchPRCheckDetails } = createOwnerStore({ + repos: [localRepo, { ...localRepo, path: '/remote', executionHostId: 'runtime:server' }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' })] + }, + detectedWorktreesByRepo: { + 'repo-1': { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '/remote', + hostId: 'runtime:server', + runtimeOwnerEnvironmentId: 'server' + }), + ownership: 'external', + selectedCheckout: false, + visible: true + } + ] + } + } + }) + const tabId = open(store) + + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(store.getState().openFiles[0]).toMatchObject({ + operationProvenance: undefined, + checkRunDetails: { error: 'Repository details are unavailable for this check.' } + }) + }) + + it.each(['repo:99', 'repo:100'])( + 'ignores a restored twin request ID when the new request context is %s', + (contextKey) => { + const localRepo = { ...TEST_REPO, id: 'repo-1', path: '/repo' } + const localWorktree = makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' }) + const catalog = { + repos: [localRepo, pairedRepo], + worktreesByRepo: { 'repo-1': [localWorktree, pairedWorktree] } + } + const source = createOwnerStore({ ...catalog, activeWorkspaceExecutionHostId: 'local' }) + const requestId = createCheckRunDetailsRequestId() + source.store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + requestId, + details: null, + loading: false, + error: null + }) + const restoredFiles = source.store.getState().openFiles.map((file) => ({ + ...file, + checkRunDetails: file.checkRunDetails && { ...file.checkRunDetails } + })) + const { store } = createOwnerStore({ + ...catalog, + openFiles: restoredFiles, + activeWorkspaceExecutionHostId: 'runtime:server' + }) + store.getState().openCheckRunDetails('wt-1', contextKey, check, { + requestId, + details: null, + loading: true, + error: null + }) + const targetId = store.getState().activeFileId + store.setState({ activeWorkspaceExecutionHostId: 'local' }) + store.getState().patchOpenCheckRunDetails('wt-1', contextKey, check, { + requestId, + details, + loading: false, + error: null + }) + + expect( + store.getState().openFiles.find((file) => file.id === targetId)?.checkRunDetails + ).toMatchObject({ details, loading: false }) + expect( + store.getState().openFiles.find((file) => file.id === restoredFiles[0].id)?.checkRunDetails + ?.details + ).toBeNull() + } + ) +}) diff --git a/src/renderer/src/store/slices/editor-check-details-tabs.test.ts b/src/renderer/src/store/slices/editor-check-details-tabs.test.ts index 03d2b85b655..81e38f361f9 100644 --- a/src/renderer/src/store/slices/editor-check-details-tabs.test.ts +++ b/src/renderer/src/store/slices/editor-check-details-tabs.test.ts @@ -1,8 +1,6 @@ -import { createStore, type StoreApi } from 'zustand/vanilla' import { describe, expect, it, vi } from 'vitest' -import { createEditorSlice } from './editor' import { createEditorTabsStore } from './editor-slice-test-harness' -import type { AppState } from '../types' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -41,29 +39,114 @@ vi.mock('@/runtime/gitlab-job-trace-client', () => ({ })) describe('createEditorSlice conflict status reconciliation', () => { - it('reloads an open check-details tab from the hosted provider', async () => { - const fetchPRCheckDetails = vi.fn().mockResolvedValue({ - name: 'verify', - status: 'completed', - conclusion: 'success', - url: null, - detailsUrl: null, - startedAt: null, - completedAt: null, - title: 'Build passed', - summary: null, - text: null, - annotations: [], - jobs: [] + it.each(['local', 'runtime:server'] as const)( + 'reloads a check-details tab opened on %s after selecting its twin', + async (executionHostId) => { + const fetchPRCheckDetails = vi.fn().mockResolvedValue({ + name: 'verify', + status: 'completed', + conclusion: 'success', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: 'Build passed', + summary: null, + text: null, + annotations: [], + jobs: [] + }) + const store = createEditorTabsStore() + const localRepo = store.getState().repos[0] + const localWorktree = store.getState().worktreesByRepo['repo-1'][0] + store.setState({ + activeWorkspaceExecutionHostId: executionHostId, + repos: [localRepo, { ...localRepo, path: '/remote', executionHostId: 'runtime:server' }], + worktreesByRepo: { + 'repo-1': [ + localWorktree, + { + ...localWorktree, + path: '/remote', + hostId: 'runtime:server', + runtimeOwnerEnvironmentId: 'server' + } + ] + }, + fetchPRCheckDetails + }) + const main = await drawTabsFromMain(store) + const check = { + name: 'verify', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + checkRunId: 42 + } + const githubRepository = { owner: 'upstream', repo: 'project' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + githubRepository + }) + await main.settle() + + const tabId = + executionHostId === 'local' + ? 'wt-1::check-details::check-run:42' + : 'wt-1::check-details@runtime:server::check-run:42' + + store.setState({ + activeWorkspaceExecutionHostId: executionHostId === 'local' ? 'runtime:server' : 'local' + }) + await store.getState().reloadOpenCheckRunDetailsTab(tabId) + + expect(fetchPRCheckDetails).toHaveBeenCalledWith( + executionHostId === 'local' ? '/repo' : '/remote', + expect.objectContaining({ + checkRunId: 42, + checkName: 'verify', + prRepo: githubRepository + }), + { repoId: 'repo-1', repoOwnerExecutionHostId: executionHostId } + ) + expect(store.getState().openFiles).toContainEqual( + expect.objectContaining({ + id: tabId, + checkRunDetails: expect.objectContaining({ + loading: false, + details: expect.objectContaining({ title: 'Build passed', conclusion: 'success' }) + }) + }) + ) + main.close() + } + ) + + it('keeps a check reopened on a twin host in its own tab with its own project', async () => { + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + const store = createEditorTabsStore() + const localRepo = store.getState().repos[0] + const localWorktree = store.getState().worktreesByRepo['repo-1'][0] + store.setState({ + activeWorkspaceExecutionHostId: 'local', + repos: [localRepo, { ...localRepo, path: '/remote', executionHostId: 'runtime:server' }], + worktreesByRepo: { + 'repo-1': [ + localWorktree, + { + ...localWorktree, + path: '/remote', + hostId: 'runtime:server', + runtimeOwnerEnvironmentId: 'server' + } + ] + }, + fetchPRCheckDetails }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = createStore()((...args: any[]) => ({ - activeWorktreeId: 'wt-1', - repos: [{ id: 'repo-1', path: '/repo' }], - worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, - fetchPRCheckDetails, - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi + const main = await drawTabsFromMain(store) const check = { name: 'verify', status: 'completed' as const, @@ -71,47 +154,146 @@ describe('createEditorSlice conflict status reconciliation', () => { url: null, checkRunId: 42 } - const githubRepository = { owner: 'upstream', repo: 'project' } + const localProject = { owner: 'local-org', repo: 'project' } + const remoteProject = { owner: 'remote-org', repo: 'project' } store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { details: null, loading: false, error: null, - githubRepository + githubRepository: localProject + }) + await main.settle() + store.setState({ activeWorkspaceExecutionHostId: 'runtime:server' }) + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + githubRepository: remoteProject + }) + await main.settle() + // A sidebar patch on the selected host must not rewrite its twin's tab. + store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: 'remote patch' }) - await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::check-run:42') + const localTabId = 'wt-1::check-details::check-run:42' + const remoteTabId = 'wt-1::check-details@runtime:server::check-run:42' + expect( + store + .getState() + .openFiles.filter((file) => file.mode === 'check-details') + .map((file) => [ + file.id, + file.checkRunDetails?.githubRepository, + file.checkRunDetails?.error + ]) + ).toEqual([ + [localTabId, localProject, null], + [remoteTabId, remoteProject, 'remote patch'] + ]) - expect(fetchPRCheckDetails).toHaveBeenCalledWith( + await store.getState().reloadOpenCheckRunDetailsTab(localTabId) + await store.getState().reloadOpenCheckRunDetailsTab(remoteTabId) + + expect(fetchPRCheckDetails).toHaveBeenNthCalledWith( + 1, '/repo', - expect.objectContaining({ - checkRunId: 42, - checkName: 'verify', - prRepo: githubRepository - }), - { repoId: 'repo-1' } + expect.objectContaining({ prRepo: localProject }), + { repoId: 'repo-1', repoOwnerExecutionHostId: 'local' } ) - expect(store.getState().openFiles).toContainEqual( - expect.objectContaining({ - id: 'wt-1::check-details::check-run:42', - checkRunDetails: expect.objectContaining({ - loading: false, - details: expect.objectContaining({ title: 'Build passed', conclusion: 'success' }) - }) - }) + expect(fetchPRCheckDetails).toHaveBeenNthCalledWith( + 2, + '/remote', + expect.objectContaining({ prRepo: remoteProject }), + { repoId: 'repo-1', repoOwnerExecutionHostId: 'runtime:server' } ) + main.close() }) + it.each(['local', 'runtime:server'] as const)( + 'reloads a check-details tab for a detected %s checkout', + async (executionHostId) => { + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + const remote = executionHostId !== 'local' + const repoPath = remote ? '/remote' : '/repo' + const store = createEditorTabsStore() + store.setState({ + activeWorktreeId: 'wt-1', + repos: [ + { + ...TEST_REPO, + id: 'repo-1', + path: repoPath, + ...(remote ? { executionHostId: 'runtime:server' as const } : {}) + } + ], + worktreesByRepo: {}, + detectedWorktreesByRepo: { + 'repo-1': { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: repoPath, + ...(remote + ? { hostId: 'runtime:server' as const, runtimeOwnerEnvironmentId: 'server' } + : {}) + }), + ownership: 'external', + selectedCheckout: false, + visible: true + } + ] + } + }, + settings: null, + fetchPRCheckDetails + }) + const check = { + name: 'verify', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + checkRunId: 42 + } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + await store + .getState() + .reloadOpenCheckRunDetailsTab( + remote + ? 'wt-1::check-details@runtime:server::check-run:42' + : 'wt-1::check-details::check-run:42' + ) + + expect(fetchPRCheckDetails).toHaveBeenCalledWith( + repoPath, + expect.objectContaining({ checkRunId: 42 }), + { repoId: 'repo-1', repoOwnerExecutionHostId: executionHostId } + ) + } + ) + it('stops loading when an open check-details tab loses its repository', async () => { const fetchPRCheckDetails = vi.fn() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = createStore()((...args: any[]) => ({ + const store = createEditorTabsStore() + store.setState({ activeWorktreeId: 'wt-1', repos: [], worktreesByRepo: {}, - fetchPRCheckDetails, - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi + fetchPRCheckDetails + }) const check = { name: 'verify', status: 'completed' as const, @@ -140,7 +322,14 @@ describe('createEditorSlice conflict status reconciliation', () => { }) // GitLab job refresh must use GitLab trace, not GitHub check-runs or the open tab blanks (#7732). - it('reloads an open GitLab job tab through the job trace client', async () => { + it.each([ + 'local', + 'runtime', + 'paired-ssh', + 'pinned-runtime', + 'pinned-local', + 'pinned-paired-ssh' + ] as const)('reloads an open GitLab job tab through its %s owner', async (owner) => { loadGitLabJobLogDetailsMock.mockReset() loadGitLabJobLogDetailsMock.mockResolvedValue({ name: 'test: unit', @@ -169,15 +358,41 @@ describe('createEditorSlice conflict status reconciliation', () => { ] }) const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = createStore()((...args: any[]) => ({ + const store = createEditorTabsStore() + store.setState({ activeWorktreeId: 'wt-1', - repos: [{ id: 'repo-1', path: '/repo' }], - worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, - settings: { activeRuntimeEnvironmentId: null }, - fetchPRCheckDetails, - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/repo' }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' })] + }, + settings: null, + fetchPRCheckDetails + }) + if (owner !== 'local') { + const local = store.getState().worktreesByRepo['repo-1'][0] + store.setState({ + repos: [ + ...store.getState().repos, + { + ...store.getState().repos[0], + path: '/remote', + executionHostId: 'runtime:server', + ...(owner.endsWith('paired-ssh') ? { connectionId: 'builder' } : {}) + } + ], + worktreesByRepo: { + 'repo-1': [ + ...(owner.startsWith('pinned-') ? [local] : []), + { + ...local, + path: '/remote', + hostId: owner.endsWith('paired-ssh') ? 'ssh:builder' : 'runtime:server', + runtimeOwnerEnvironmentId: 'server' + } + ] + } + }) + } const check = { name: 'test: unit', status: 'completed' as const, @@ -186,21 +401,55 @@ describe('createEditorSlice conflict status reconciliation', () => { gitlabJobId: 42 } + if (owner.startsWith('pinned-')) { + store.setState({ + activeWorkspaceExecutionHostId: owner === 'pinned-local' ? 'local' : 'runtime:server' + }) + } + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { details: null, loading: false, error: null }) + if (owner.startsWith('pinned-')) { + store.setState({ + activeWorkspaceExecutionHostId: owner === 'pinned-local' ? 'runtime:server' : 'local' + }) + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + } - await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:42') + // Reopening on the other host opens that host's own tab; the first tab keeps its owner. + const ownerTabId = { + local: 'wt-1::check-details::gitlab-job:42', + runtime: 'wt-1::check-details@runtime:server::gitlab-job:42', + 'paired-ssh': 'wt-1::check-details@ssh:builder>runtime:server::gitlab-job:42', + 'pinned-runtime': 'wt-1::check-details@runtime:server::gitlab-job:42', + 'pinned-local': 'wt-1::check-details::gitlab-job:42', + 'pinned-paired-ssh': 'wt-1::check-details@runtime:server::gitlab-job:42' + }[owner] + expect(store.getState().openFiles.filter((file) => file.mode === 'check-details')).toHaveLength( + owner.startsWith('pinned-') ? 2 : 1 + ) + await store.getState().reloadOpenCheckRunDetailsTab(ownerTabId) expect(fetchPRCheckDetails).not.toHaveBeenCalled() expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/repo', repoId: 'repo-1', check }) + expect.objectContaining({ + repoPath: owner === 'local' || owner === 'pinned-local' ? '/repo' : '/remote', + repoId: 'repo-1', + repoOwnerExecutionHostId: + owner === 'local' || owner === 'pinned-local' ? 'local' : 'runtime:server', + check + }) ) expect(store.getState().openFiles).toContainEqual( expect.objectContaining({ - id: 'wt-1::check-details::gitlab-job:42', + id: ownerTabId, checkRunDetails: expect.objectContaining({ loading: false, error: null, @@ -212,19 +461,58 @@ describe('createEditorSlice conflict status reconciliation', () => { ) }) + it('refuses to reload an unqualified check tab when worktree owners conflict', async () => { + loadGitLabJobLogDetailsMock.mockReset() + const store = createEditorTabsStore() + const local = store.getState().worktreesByRepo['repo-1'][0] + store.setState({ + repos: [ + ...store.getState().repos, + { ...store.getState().repos[0], executionHostId: 'runtime:server' } + ], + worktreesByRepo: { + 'repo-1': [ + local, + { ...local, hostId: 'runtime:server', runtimeOwnerEnvironmentId: 'server' } + ] + } + }) + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 42 + } + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:42') + + expect(loadGitLabJobLogDetailsMock).not.toHaveBeenCalled() + expect(store.getState().openFiles[0].checkRunDetails).toMatchObject({ + loading: false, + error: 'Repository details are unavailable for this check.' + }) + }) + // Fork MR job traces live in source project; retain stored project ref on reload (#7732). it('reloads a fork MR job tab with the stored GitLab project ref', async () => { loadGitLabJobLogDetailsMock.mockReset() loadGitLabJobLogDetailsMock.mockResolvedValue(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = createStore()((...args: any[]) => ({ + const store = createEditorTabsStore() + store.setState({ activeWorktreeId: 'wt-1', - repos: [{ id: 'repo-1', path: '/repo' }], - worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, - settings: { activeRuntimeEnvironmentId: null }, - fetchPRCheckDetails: vi.fn().mockResolvedValue(null), - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/repo' }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' })] + }, + settings: null, + fetchPRCheckDetails: vi.fn().mockResolvedValue(null) + }) const check = { name: 'test: unit', status: 'completed' as const, @@ -249,11 +537,7 @@ describe('createEditorSlice conflict status reconciliation', () => { }) it('keeps a stored GitLab project ref when a patch omits it', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = createStore()((...args: any[]) => ({ - activeWorktreeId: 'wt-1', - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi + const store = createEditorTabsStore() const check = { name: 'test: unit', status: 'completed' as const, diff --git a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts index 8dd5cc06ee9..747a39e96df 100644 --- a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts @@ -12,11 +12,16 @@ import { import type { OpenCheckRunDetailsState } from '../../../../../../shared/editor-tab-view-types' import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' -import { findWorktreeById, getRepoIdFromWorktreeId } from '../../worktree-helpers' import type { OpenFile } from '../types/open-file' import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item' import { reconcileCheckDetailsReadingCache } from '@/components/editor/check-details-reading-cache' -import { selectWorktreesByRepo } from '../../../workspace-record-selectors' +import { + findCheckRequestTabId, + findCheckRunDetailsOwner, + getCheckRunDetailsOwnerScope, + isSameCheckRunDetailsOwner, + resolveCheckRunDetailsOwnership +} from './check-run-details-owner' export function createCheckRunDetailsActions( set: EditorSet, @@ -25,9 +30,82 @@ export function createCheckRunDetailsActions( EditorSlice, 'openCheckRunDetails' | 'patchOpenCheckRunDetails' | 'reloadOpenCheckRunDetailsTab' > { + // Restored numeric request IDs belong to a previous renderer lifetime. + const requests = new WeakSet() + const patchCheckRunDetailsTab = ( + id: string, + contextKey: string, + check: OpenCheckRunDetailsState['check'], + state: CheckRunDetailsTabPatch + ): void => { + let accepted: { previous: OpenCheckRunDetailsState; next: OpenCheckRunDetailsState } | undefined + set((s) => { + const existing = s.openFiles.find((f) => f.id === id) + if (existing?.mode !== 'check-details' || !existing.checkRunDetails) { + return s + } + const current = existing.checkRunDetails + if (current.contextKey !== contextKey) { + return s + } + if ( + state.requestId !== undefined && + current.requestId !== undefined && + state.requestId < current.requestId + ) { + return s + } + // Async sidebar project lookup must not clear a previously known MR ref. + const githubRepository = state.githubRepository ?? current.githubRepository ?? null + const gitlabProjectRef = state.gitlabProjectRef ?? current.gitlabProjectRef ?? null + const nextCheckRunDetails: OpenCheckRunDetailsState = { + contextKey, + check, + requestId: state.requestId ?? current.requestId, + details: state.details, + loading: state.loading, + error: state.error, + githubRepository, + gitlabProjectRef + } + if ( + current.contextKey === nextCheckRunDetails.contextKey && + current.requestId === nextCheckRunDetails.requestId && + current.check.status === nextCheckRunDetails.check.status && + current.check.conclusion === nextCheckRunDetails.check.conclusion && + current.loading === nextCheckRunDetails.loading && + current.error === nextCheckRunDetails.error && + current.details === nextCheckRunDetails.details && + isSameGitHubRepository(current.githubRepository ?? null, githubRepository) && + isSameGitLabProjectRef(current.gitlabProjectRef ?? null, gitlabProjectRef) + ) { + return s + } + accepted = { previous: current, next: nextCheckRunDetails } + return { + openFiles: s.openFiles.map((f) => + f.id === id ? { ...f, checkRunDetails: nextCheckRunDetails } : f + ) + } + }) + if (accepted) { + const { previous, next } = accepted + requests.add(next) + reconcileCheckDetailsReadingCache(id, previous, next, () => { + const live = get().openFiles.find((file) => file.id === id) + return live?.mode === 'check-details' && live.checkRunDetails === next + }) + } + } + return { openCheckRunDetails: (worktreeId, contextKey, check, state) => { - const id = buildCheckRunDetailsTabId(worktreeId, check) + const resolvedOwnership = resolveCheckRunDetailsOwnership(get(), worktreeId) + const id = buildCheckRunDetailsTabId( + worktreeId, + check, + getCheckRunDetailsOwnerScope(resolvedOwnership) + ) const label = getCheckRunDetailsTabLabel(check) const checkRunDetails: OpenCheckRunDetailsState = { contextKey, @@ -40,10 +118,21 @@ export function createCheckRunDetailsActions( gitlabProjectRef: state.gitlabProjectRef ?? null } let accepted: - | { previous: OpenCheckRunDetailsState | undefined; next: OpenCheckRunDetailsState } + | { + previous: OpenCheckRunDetailsState | undefined + next: OpenCheckRunDetailsState + } | undefined set((s) => { const existing = s.openFiles.find((f) => f.id === id) + // Why: an open whose owner cannot be resolved must not leave the tab reloading through its old owner. + const ownership = + existing && isSameCheckRunDetailsOwner(existing, resolvedOwnership) + ? {} + : (resolvedOwnership ?? { + runtimeEnvironmentId: undefined, + operationProvenance: undefined + }) if (existing) { const existingDetails = existing.checkRunDetails const incomingIsStale = @@ -62,6 +151,7 @@ export function createCheckRunDetailsActions( f.id === id ? { ...f, + ...ownership, mode: 'check-details' as const, relativePath: label, language: 'plaintext', @@ -71,8 +161,14 @@ export function createCheckRunDetailsActions( ), activeFileId: id, activeTabType: 'editor', - activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + activeFileIdByWorktree: { + ...s.activeFileIdByWorktree, + [worktreeId]: id + }, + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [worktreeId]: 'editor' + } } } @@ -82,6 +178,7 @@ export function createCheckRunDetailsActions( filePath: id, relativePath: label, worktreeId, + ...ownership, language: 'plaintext', isDirty: false, mode: 'check-details', @@ -92,12 +189,19 @@ export function createCheckRunDetailsActions( openFiles: [...s.openFiles, newFile], activeFileId: id, activeTabType: 'editor', - activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + activeFileIdByWorktree: { + ...s.activeFileIdByWorktree, + [worktreeId]: id + }, + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [worktreeId]: 'editor' + } } }) if (accepted) { const { previous, next } = accepted + requests.add(next) reconcileCheckDetailsReadingCache(id, previous, next, () => { const live = get().openFiles.find((file) => file.id === id) return live?.mode === 'check-details' && live.checkRunDetails === next @@ -108,66 +212,16 @@ export function createCheckRunDetailsActions( // Why: sidebar detail fetches can finish after the full-details tab is open; update the snapshot without stealing focus. patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => { - const id = buildCheckRunDetailsTabId(worktreeId, check) - let accepted: - | { previous: OpenCheckRunDetailsState; next: OpenCheckRunDetailsState } - | undefined - set((s) => { - const existing = s.openFiles.find((f) => f.id === id) - if (existing?.mode !== 'check-details' || !existing.checkRunDetails) { - return s - } - const current = existing.checkRunDetails - if (current.contextKey !== contextKey) { - return s - } - if ( - state.requestId !== undefined && - current.requestId !== undefined && - state.requestId < current.requestId - ) { - return s - } - // Async sidebar project lookup must not clear a previously known MR ref. - const githubRepository = state.githubRepository ?? current.githubRepository ?? null - const gitlabProjectRef = state.gitlabProjectRef ?? current.gitlabProjectRef ?? null - const nextCheckRunDetails: OpenCheckRunDetailsState = { - contextKey, + const live = get() + // Why: a request's completion belongs to the tab it started on, even after the user selects a twin host. + const id = + findCheckRequestTabId(live.openFiles, worktreeId, check, state.requestId, requests) ?? + buildCheckRunDetailsTabId( + worktreeId, check, - requestId: state.requestId ?? current.requestId, - details: state.details, - loading: state.loading, - error: state.error, - githubRepository, - gitlabProjectRef - } - if ( - current.contextKey === nextCheckRunDetails.contextKey && - current.requestId === nextCheckRunDetails.requestId && - current.check.status === nextCheckRunDetails.check.status && - current.check.conclusion === nextCheckRunDetails.check.conclusion && - current.loading === nextCheckRunDetails.loading && - current.error === nextCheckRunDetails.error && - current.details === nextCheckRunDetails.details && - isSameGitHubRepository(current.githubRepository ?? null, githubRepository) && - isSameGitLabProjectRef(current.gitlabProjectRef ?? null, gitlabProjectRef) - ) { - return s - } - accepted = { previous: current, next: nextCheckRunDetails } - return { - openFiles: s.openFiles.map((f) => - f.id === id ? { ...f, checkRunDetails: nextCheckRunDetails } : f - ) - } - }) - if (accepted) { - const { previous, next } = accepted - reconcileCheckDetailsReadingCache(id, previous, next, () => { - const live = get().openFiles.find((file) => file.id === id) - return live?.mode === 'check-details' && live.checkRunDetails === next - }) - } + getCheckRunDetailsOwnerScope(resolveCheckRunDetailsOwnership(live, worktreeId)) + ) + patchCheckRunDetailsTab(id, contextKey, check, state) }, reloadOpenCheckRunDetailsTab: async (fileId) => { @@ -185,15 +239,16 @@ export function createCheckRunDetailsActions( )?.checkRunDetails const details = next.details ?? (current?.contextKey === contextKey ? current.details : null) - get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, { + // Why: the active host can differ from the tab's owner, so patch this tab rather than re-deriving its id. + patchCheckRunDetailsTab(fileId, contextKey, check, { ...next, details, requestId }) } - const worktree = findWorktreeById(selectWorktreesByRepo(state), file.worktreeId) - const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) - const repo = state.repos.find((candidate) => candidate.id === repoId) + const owner = findCheckRunDetailsOwner(state, file) + const worktree = owner?.worktree + const repo = owner?.repo if (!repo?.path) { patch({ details: checkRunDetails.details, @@ -210,12 +265,13 @@ export function createCheckRunDetailsActions( } patch({ details: checkRunDetails.details, loading: true, error: null }) try { + const repoOwnerExecutionHostId = forgeOwnerHostIdForWorkspace(repo, worktree) // GitLab jobs require GitLab trace; GitHub check-runs returns null and blanks the refreshed tab. const details = check.gitlabJobId ? await loadGitLabJobLogDetails({ repoPath: repo.path, repoId: repo.id, - repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, worktree), + repoOwnerExecutionHostId, check, // Why: a fork MR's job lives in the source project, not the repo's own. projectRef: checkRunDetails.gitlabProjectRef ?? null @@ -229,7 +285,7 @@ export function createCheckRunDetailsActions( url: check.url, prRepo: checkRunDetails.githubRepository ?? null }, - { repoId: repo.id } + { repoId: repo.id, repoOwnerExecutionHostId } ) patch({ details, diff --git a/src/renderer/src/store/slices/editor/actions/check-run-details-owner.ts b/src/renderer/src/store/slices/editor/actions/check-run-details-owner.ts new file mode 100644 index 00000000000..35d596e6230 --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/check-run-details-owner.ts @@ -0,0 +1,238 @@ +import type { Repo } from '../../../../../../shared/repo-types' +import { resolveReviewRepository } from '@/lib/review-owner' +import type { AppState } from '../../../types' +import type { OpenFile } from '../types/open-file' +import type { OpenCheckRunDetailsState } from '../../../../../../shared/editor-tab-view-types' +import { getCheckRunTabIdentity } from '@/components/editor/check-run-details-tab' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../../../shared/execution-host' +import { checkRunDetailsOwnerScope } from '../../../../../../shared/check-run-details-owner-scope' +import { + getOpenFileExecutionHostId, + hasOpenFileExecutionHostEvidence +} from '@/lib/unified-tab-host-ownership' +import { findIndexedReviewRepository } from '@/lib/repository-owner-index' +import { + findIndexedDetectedWorktrees, + findIndexedWorktreeOwners +} from '@/lib/worktree-runtime-owner-index' +import { layoutFrameWorkspace } from '@/runtime/layout-frame-workspaces' +import { + resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteForHost, + type WorktreeOperationOwnerRecord, + type WorktreeOperationRoute +} from '@/lib/worktree-operation-route' +import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route' +import { onPairedWebClientHost } from '@/lib/paired-web-client-host' +import { captureWorktreeOperationGenerationSnapshot } from '@/lib/worktree-operation-generation' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' + +export type CheckRunDetailsOwnership = Pick< + OpenFile, + 'runtimeEnvironmentId' | 'operationProvenance' +> + +type CheckRunDetailsOwnerFile = Pick< + OpenFile, + 'id' | 'externalSshTargetId' | 'operationProvenance' | 'runtimeEnvironmentId' | 'worktreeId' +> + +export function resolveCheckRunDetailsOwnership( + state: AppState, + worktreeId: string +): CheckRunDetailsOwnership | null { + const route = resolveWorktreeOperationRoute( + { ...state, worktreesByRepo: selectWorktreesByRepo(state) }, + worktreeId + ) + return route + ? { + runtimeEnvironmentId: route.runtimeEnvironmentId, + operationProvenance: { + generation: captureWorktreeOperationGenerationSnapshot(route), + ownershipProjection: 'explicit' + } + } + : null +} + +/** Worktree ids repeat across hosts, so a remote owner keys its own tab instead of taking over a twin's. */ +export function getCheckRunDetailsOwnerScope( + ownership: CheckRunDetailsOwnership | null +): string | null { + if (!ownership) { + return null + } + return checkRunDetailsOwnerScope( + getOpenFileExecutionHostId(ownership), + ownership.runtimeEnvironmentId + ) +} + +export function isSameCheckRunDetailsOwner( + file: CheckRunDetailsOwnerFile, + ownership: CheckRunDetailsOwnership | null +): boolean { + return ( + ownership !== null && + hasOpenFileExecutionHostEvidence(file) && + getOpenFileExecutionHostId(file) === getOpenFileExecutionHostId(ownership) && + (file.runtimeEnvironmentId?.trim() || null) === (ownership.runtimeEnvironmentId?.trim() || null) + ) +} + +/** The tab a sidebar request stamped when it started; the host selected at completion can differ. */ +export function findCheckRequestTabId( + openFiles: readonly OpenFile[], + worktreeId: string, + check: OpenCheckRunDetailsState['check'], + requestId: number | undefined, + liveRequests: WeakSet +): string | null { + if (requestId === undefined) { + return null + } + const identity = getCheckRunTabIdentity(check) + const tab = openFiles.find( + (file) => + file.mode === 'check-details' && + file.worktreeId === worktreeId && + file.checkRunDetails?.requestId === requestId && + liveRequests.has(file.checkRunDetails) && + getCheckRunTabIdentity(file.checkRunDetails.check) === identity + ) + return tab?.id ?? null +} + +function runtimeOf(route: WorktreeOperationRoute): string | null { + return route.runtimeEnvironmentId?.trim() || null +} + +function getTabRoute(file: CheckRunDetailsOwnerFile): WorktreeOperationRoute { + return ( + file.operationProvenance?.generation.route ?? { + executionHostId: getOpenFileExecutionHostId(file), + runtimeEnvironmentId: file.runtimeEnvironmentId?.trim() || null + } + ) +} + +function getFramedTabRoute(file: CheckRunDetailsOwnerFile): WorktreeOperationRoute | null { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return null + } + const tabs = layoutFrameWorkspace(file.worktreeId)?.tabs.filter( + (tab) => tab.kind === 'check-details' && tab.entityId === file.id + ) + const hosts = new Set(tabs?.map((tab) => tab.executionHostId)) + const host = hosts.size === 1 ? parseExecutionHostId(hosts.values().next().value) : null + return host + ? { + executionHostId: host.id, + runtimeEnvironmentId: + file.runtimeEnvironmentId?.trim() || (host.kind === 'runtime' ? host.environmentId : null) + } + : null +} + +/** The route a listing row is reached through, with a legacy SSH row's server recovered from its repo. */ +function getRowRoute(state: AppState, row: WorktreeOperationOwnerRecord, repository?: Repo) { + if (!row.hostId && !row.runtimeOwnerEnvironmentId?.trim()) { + return onPairedWebClientHost(state, { + executionHostId: LOCAL_EXECUTION_HOST_ID, + runtimeEnvironmentId: null + }) + } + const resolution = resolveExactWorktreeRoute( + repository ? { ...state, repos: [repository] } : state, + row + ) + if (resolution.kind === 'resolved') { + return onPairedWebClientHost(state, resolution.route) + } + if (resolution.kind !== 'ambiguous' || !row.hostId) { + return null + } + // Opening can prove a direct listing despite a repo proxy; retain that fallback without adopting a server's row. + const directRoute = onPairedWebClientHost(state, { + executionHostId: row.hostId, + runtimeEnvironmentId: null + }) + const qualifiedRoute = resolveWorktreeOperationRouteForHost( + { + ...state, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state) + }, + row.id, + row.hostId + ) + return qualifiedRoute && isTabOwnerRoute(directRoute, qualifiedRoute) ? qualifiedRoute : null +} + +function isTabOwnerRoute(tab: WorktreeOperationRoute, row: WorktreeOperationRoute): boolean { + const runtime = runtimeOf(tab) + if (runtimeOf(row) !== runtime) { + return false + } + // Why: a tab opened through its server names the server, not the SSH machine that server reaches. + return ( + row.executionHostId === tab.executionHostId || + (runtime !== null && tab.executionHostId === toRuntimeExecutionHostId(runtime)) + ) +} + +/** Tracked and detected rows; rival owners, or none on a pinned tab's route, fail closed. */ +export function findCheckRunDetailsOwner( + state: AppState, + file: CheckRunDetailsOwnerFile +): { worktree: WorktreeOperationOwnerRecord; repo: Repo } | null { + // Rebuilt local/direct-SSH views lack provenance; main's published tab still qualifies their owner. + const tabRoute = hasOpenFileExecutionHostEvidence(file) + ? getTabRoute(file) + : getFramedTabRoute(file) + const rows = [ + ...findIndexedWorktreeOwners(selectWorktreesByRepo(state), file.worktreeId), + ...findIndexedDetectedWorktrees(selectDetectedWorktreesByRepo(state), file.worktreeId) + ] + const matches = new Map() + const runtime = tabRoute && runtimeOf(tabRoute) + for (const row of rows) { + const selectedRepo = runtime + ? findIndexedReviewRepository( + state.repos, + row.repoId, + toRuntimeExecutionHostId(runtime), + parseExecutionHostId(row.hostId)?.kind === 'ssh' ? row.hostId : undefined + ) + : undefined + if (runtime && !selectedRepo) { + continue + } + const repo = resolveReviewRepository(state.repos, row, selectedRepo ?? undefined) + if (tabRoute && !repo) { + continue + } + const rowRoute = tabRoute ? getRowRoute(state, row, selectedRepo ?? undefined) : null + if (!tabRoute || (rowRoute && isTabOwnerRoute(tabRoute, rowRoute))) { + matches.set( + JSON.stringify([ + row.repoId, + row.hostId ?? null, + rowRoute?.runtimeEnvironmentId ?? row.runtimeOwnerEnvironmentId ?? null + ]), + { worktree: row, repo } + ) + } + } + const match = matches.size === 1 ? matches.values().next().value : null + return match?.repo ? { worktree: match.worktree, repo: match.repo } : null +} diff --git a/src/renderer/src/store/slices/editor/actions/git-operation-target.ts b/src/renderer/src/store/slices/editor/actions/git-operation-target.ts index 14b308891ad..df295bd6e67 100644 --- a/src/renderer/src/store/slices/editor/actions/git-operation-target.ts +++ b/src/renderer/src/store/slices/editor/actions/git-operation-target.ts @@ -1,4 +1,6 @@ import { toast } from 'sonner' +import { getConnectionExecutionHostId } from '../../../../../../shared/execution-host' +import { getRuntimeTargetHostId } from '@/runtime/runtime-client-target' import type { AppState } from '../../../types' import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' @@ -31,3 +33,12 @@ export function resolveGitOperationTarget( } return target } + +export function gitOperationExecutionHostId( + target: RuntimeClientTarget, + connectionId?: string | null +): string { + return target.kind === 'environment' + ? getRuntimeTargetHostId(target) + : getConnectionExecutionHostId(connectionId) +} diff --git a/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts b/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts index 2b83a7a4ae4..d254f6b6ccb 100644 --- a/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts +++ b/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts @@ -1,6 +1,6 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' -import { resolveGitOperationTarget } from './git-operation-target' +import { gitOperationExecutionHostId, resolveGitOperationTarget } from './git-operation-target' import { toast } from 'sonner' import { fetchRuntimeGit, @@ -66,7 +66,10 @@ export function createGitRemotePushPull( }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { - refreshGitHubForWorktree(worktreeId) + refreshGitHubForWorktree( + worktreeId, + gitOperationExecutionHostId(runtimeTarget, connectionId) + ) } }, pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { @@ -88,7 +91,10 @@ export function createGitRemotePushPull( }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { - refreshGitHubForWorktree(worktreeId) + refreshGitHubForWorktree( + worktreeId, + gitOperationExecutionHostId(runtimeTarget, connectionId) + ) } }, fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { @@ -110,7 +116,10 @@ export function createGitRemotePushPull( }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { - refreshGitHubForWorktree(worktreeId) + refreshGitHubForWorktree( + worktreeId, + gitOperationExecutionHostId(runtimeTarget, connectionId) + ) } } } diff --git a/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts b/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts index 5972724aff6..cb5ca399218 100644 --- a/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts +++ b/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts @@ -1,6 +1,6 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' -import { resolveGitOperationTarget } from './git-operation-target' +import { gitOperationExecutionHostId, resolveGitOperationTarget } from './git-operation-target' import { toast } from 'sonner' import { fetchRuntimeGit, @@ -81,7 +81,10 @@ export function createGitRemoteSync( if (pushed) { const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { - refreshGitHubForWorktree(worktreeId) + refreshGitHubForWorktree( + worktreeId, + gitOperationExecutionHostId(runtimeTarget, connectionId) + ) } } }, @@ -111,7 +114,10 @@ export function createGitRemoteSync( }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { - refreshGitHubForWorktree(worktreeId) + refreshGitHubForWorktree( + worktreeId, + gitOperationExecutionHostId(runtimeTarget, connectionId) + ) } }, fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { diff --git a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts index 87d308f105c..b23b7e6e421 100644 --- a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts +++ b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts @@ -49,7 +49,7 @@ export function openWorkspaceEditorItem( } const drawHere = (id?: string): Tab | undefined => { const file = - contentType === 'editor' + contentType === 'editor' || contentType === 'check-details' ? state.openFiles.find((entry) => entry.id === fileId && entry.worktreeId === worktreeId) : undefined return state.createUnifiedTab?.(worktreeId, contentType, { diff --git a/src/renderer/src/store/slices/github-linked-review-clear-ownership.test.ts b/src/renderer/src/store/slices/github-linked-review-clear-ownership.test.ts new file mode 100644 index 00000000000..944c0d06cb4 --- /dev/null +++ b/src/renderer/src/store/slices/github-linked-review-clear-ownership.test.ts @@ -0,0 +1,505 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { createWorktreeIdentity } from '../../../../shared/worktree/identity' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' +import { forgeOwnerHostIdForWorkspace } from '../../runtime/forge-credential-target' +import { + _clearGitHubPRRefreshStartedEntriesForTest, + prRefreshStartedLinkedReviewScopes +} from '../github/request-coordination' +import { bumpHostedReviewLinkMutationGeneration } from './worktrees/metadata/hosted-review-link-mutation' +import { createUpdateWorktreeMeta } from './worktrees/metadata/update-worktree-meta' +import { findKnownWorktreeById } from './worktrees/listing/detected-worktree-meta' +import { + createTestStore, + makePR, + makePRRefreshWorktree, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from './github-slice-test-harness' + +const repository: Repo = { + id: 'project', + path: '/project', + displayName: 'Project', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:server', + connectionId: 'builder' +} +const branch = 'feature/review' +// Why: the cache route relies on isFresh(), so the frozen clock keeps that entry fresh deterministically. +const NOW = Date.UTC(2026, 0, 1) + +function setup(stamped: boolean, durableIdentity = true) { + const store = createTestStore() + const workspace = makePRRefreshWorktree({ + id: 'project::/project/worktree', + repoId: 'project', + path: '/project/worktree', + branch, + hostId: 'ssh:builder', + ...(stamped ? { runtimeOwnerEnvironmentId: 'server' } : {}), + identity: durableIdentity + ? createWorktreeIdentity({ + worktreeId: 'project::/project/worktree', + executionHostId: 'ssh:builder', + instanceId: 'original' + }) + : undefined, + linkedPR: 12, + head: 'current-head' + }) + const sibling = { + ...workspace, + hostId: 'local' as const, + runtimeOwnerEnvironmentId: undefined, + identity: durableIdentity + ? createWorktreeIdentity({ + worktreeId: workspace.id, + executionHostId: 'local', + instanceId: 'sibling' + }) + : undefined + } + const localRepo = { ...repository, connectionId: null, executionHostId: 'local' as const } + const update = vi.fn(createUpdateWorktreeMeta(store.setState, store.getState)) + store.setState({ + repos: [localRepo, repository], + worktreesByRepo: { project: [workspace, sibling] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + sortEpoch: 0, + settings: null, + activeWorkspaceExecutionHostId: 'local', + getKnownWorktreeById: (id, host) => findKnownWorktreeById(store.getState(), id, host), + updateWorktreeMeta: update, + fetchHostedReviewForBranch: vi.fn().mockResolvedValue(null) + }) + const key = getGitHubPRCacheKey( + repository.path, + repository.id, + branch, + null, + repository.connectionId, + repository.executionHostId, + true + ) + const alias = { + cacheKey: key, + repoId: repository.id, + repoPath: repository.path, + branch, + executionHostId: repository.executionHostId, + connectionId: repository.connectionId, + worktreeId: workspace.id, + linkedPRNumber: 12, + currentHeadOid: workspace.head + } + const options = { + repoId: repository.id, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repository, workspace), + worktreeId: workspace.id, + linkedPRNumber: 12 + } + return { store, workspace, sibling, update, key, alias, options } +} +const diverged = makePR({ + number: 12, + state: 'merged', + headSha: 'merged-head', + headDivergedFromMergedPRAtOid: 'current-head' +}) + +describe('linked review clearing retains workspace and credential identities', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + vi.clearAllMocks() + _clearGitHubPRRefreshStartedEntriesForTest() + resetRemoteRuntimeMocks() + runtimeEnvironmentCall.mockImplementation(({ method }) => ({ + id: method, + ok: true, + result: method === 'github.prForBranch' ? diverged : {}, + _meta: { runtimeId: 'server' } + })) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each([100, 1000])( + 'bounds accepted refresh-start repository reads with %s repositories and overlapping SSH routes', + (population) => { + const { store, workspace, alias } = setup(false) + let reads = 0 + const repos = Array.from({ length: population - 2 }, (_, index) => ({ + ...repository, + get id() { + reads++ + return `unrelated-${index}` + } + })) + const direct: Repo = { ...repository, executionHostId: 'ssh:builder' } + const directAlias = { + ...alias, + cacheKey: `ssh:builder::${repository.id}::${branch}`, + executionHostId: direct.executionHostId + } + store.setState({ + repos: [...repos, repository, direct], + worktreesByRepo: { project: [workspace] } + }) + const start = (sequence: number) => + store.getState().applyGitHubPRRefreshEvent({ + sequence, + reason: 'swr', + aliases: [directAlias], + status: 'in-flight', + requestStartedAt: NOW + }) + start(1) + reads = 0 + for (let sequence = 2; sequence <= 101; sequence++) { + start(sequence) + } + expect(store.getState().prRefreshSequences[directAlias.cacheKey]).toBe(101) + expect(prRefreshStartedLinkedReviewScopes.get(directAlias.cacheKey)?.scope.workspace).toBe( + workspace + ) + expect(reads).toBe(0) + } + ) + + it.each([100, 1000])( + 'bounds linked SSH request-start reads across %s workspaces', + async (population) => { + const { store, workspace, sibling, update, alias } = setup(true) + let reads = 0 + const rows = Array.from({ length: population }, (_, index) => { + const id = index === 0 ? workspace.id : `project::/unrelated-${index}` + return { + ...workspace, + get id() { + reads++ + return id + } + } + }) + store.setState({ worktreesByRepo: { project: [...rows, sibling] } }) + const start = (sequence: number) => + store.getState().applyGitHubPRRefreshEvent({ + sequence, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + start(1) + expect(reads).toBeGreaterThanOrEqual(population) + expect(reads).toBeLessThanOrEqual(3 * population + 10) + reads = 0 + for (let sequence = 2; sequence <= population + 1; sequence++) { + start(sequence) + } + expect(reads).toBeLessThanOrEqual(2 * population) + store.getState().applyGitHubPRRefreshEvent({ + sequence: population + 1, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: diverged, fetchedAt: NOW } + }) + await update.mock.results[0]?.value + expect(update).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + method: 'worktree.set', + params: expect.objectContaining({ linkedPR: null }) + }) + ) + expect( + findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')?.linkedPR + ).toBeNull() + expect(findKnownWorktreeById(store.getState(), workspace.id, 'local')).toBe(sibling) + } + ) + + for (const stamped of [false, true]) { + it.each(['cache', 'lookup', 'coordinator'] as const)( + `persists the paired copy through %s (stamp: ${stamped})`, + async (route) => { + const { store, workspace, sibling, update, key, alias, options } = setup(stamped) + if (route === 'cache') { + store.setState({ prCache: { [key]: { data: diverged, fetchedAt: NOW } } }) + } + if (route === 'coordinator') { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 71, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 71, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: diverged, fetchedAt: NOW } + }) + } else { + await store.getState().fetchPRForBranch(repository.path, branch, options) + } + await update.mock.results[0]?.value + expect(update).toHaveBeenCalledTimes(1) + expect( + findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')?.linkedPR + ).toBeNull() + expect(findKnownWorktreeById(store.getState(), workspace.id, 'local')).toBe(sibling) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + method: 'worktree.set', + params: expect.objectContaining({ + worktree: `identity:${workspace.identity?.key}`, + linkedPR: null + }) + }) + ) + } + ) + } + + it.each(['lookup', 'coordinator'] as const)( + 'persists a branch-mismatch clear through %s', + async (route) => { + const { store, workspace, sibling, update, alias, options } = setup(true) + const mismatch = makePR({ + number: 12, + state: 'open', + headSha: 'old-head', + headRefName: 'feature/old' + }) + runtimeEnvironmentCall.mockImplementation(({ method }) => ({ + id: method, + ok: true, + result: method === 'github.prForBranch' ? mismatch : {}, + _meta: { runtimeId: 'server' } + })) + if (route === 'lookup') { + await store.getState().fetchPRForBranch(repository.path, branch, options) + } else { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 74, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 74, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: mismatch, fetchedAt: NOW } + }) + } + await update.mock.results[0]?.value + expect( + findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')?.linkedPR + ).toBeNull() + expect(findKnownWorktreeById(store.getState(), workspace.id, 'local')).toBe(sibling) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + method: 'worktree.set', + params: expect.objectContaining({ + worktree: `identity:${workspace.identity?.key}`, + linkedPR: null + }) + }) + ) + } + ) + + it.each(['removed', 'ambiguous'] as const)( + 'handles a branch refetch whose owner becomes %s during unlinking', + async (ownerState) => { + const { store, update, options } = setup(true) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mismatch = makePR({ + number: 12, + state: 'open', + headSha: 'old-head', + headRefName: 'feature/old' + }) + runtimeEnvironmentCall.mockImplementation(({ method }) => ({ + id: method, + ok: true, + result: mismatch, + _meta: { runtimeId: 'server' } + })) + update.mockImplementationOnce(async () => { + store.setState({ + repos: ownerState === 'removed' ? [] : [repository, { ...repository }] + }) + return { ok: true } + }) + + await expect( + store.getState().fetchPRForBranch(repository.path, branch, options) + ).resolves.toEqual(mismatch) + + expect(update).toHaveBeenCalledOnce() + expect(warning).toHaveBeenCalledWith( + 'Branch-scoped PR refetch skipped:', + new Error('The repository is no longer available on its owning host. Reconnect and retry.') + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + { route: 'lookup', durable: true }, + { route: 'coordinator', durable: true }, + { route: 'lookup', durable: false }, + { route: 'coordinator', durable: false } + ] as const)( + 'does not clear a replacement with identical branch, head and PR through $route (durable identity: $durable)', + async ({ route, durable }) => { + const { store, workspace, sibling, update, alias, options } = setup(true, durable) + const replacement = { + ...workspace, + identity: durable + ? createWorktreeIdentity({ + worktreeId: workspace.id, + executionHostId: 'ssh:builder', + instanceId: 'replacement' + }) + : undefined + } + let finish: (response: unknown) => void = () => {} + runtimeEnvironmentCall.mockImplementation(({ method }) => + method === 'github.prForBranch' + ? new Promise((resolve) => { + finish = resolve + }) + : { id: method, ok: true, result: {}, _meta: { runtimeId: 'server' } } + ) + const request = + route === 'lookup' + ? store.getState().fetchPRForBranch(repository.path, branch, options) + : undefined + if (route === 'lookup') { + await vi.waitFor(() => + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'github.prForBranch' }) + ) + ) + } else { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 72, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + } + store.setState({ worktreesByRepo: { project: [replacement, sibling] } }) + if (route === 'lookup') { + finish({ id: 'done', ok: true, result: diverged, _meta: { runtimeId: 'server' } }) + await request + } else { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 72, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: diverged, fetchedAt: NOW } + }) + } + await update.mock.results[0]?.value + expect(findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')).toBe(replacement) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.set' }) + ) + } + ) + + it('does not clear a manually relinked review whose final branch, head and link are unchanged', async () => { + const { store, workspace, update, alias } = setup(true) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 75, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + bumpHostedReviewLinkMutationGeneration(workspace.id, workspace) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 75, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: diverged, fetchedAt: NOW } + }) + await update.mock.results[0]?.value + expect(findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')).toBe(workspace) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.set' }) + ) + }) + + it('does not dispatch a host-only refetch after a valid metadata save with nested repository twins', async () => { + const { store, workspace } = setup(true) + store.setState({ repos: [repository, { ...repository, connectionId: 'other' }] }) + await expect( + store + .getState() + .updateWorktreeMeta(workspace.id, { linkedPR: null }, { executionHostId: 'ssh:builder' }) + ).resolves.toEqual({ ok: true }) + expect(store.getState().fetchHostedReviewForBranch).not.toHaveBeenCalled() + }) + + it('preserves a push target updated while a branch-mismatch lookup was pending', async () => { + const { store, workspace, sibling, update, alias } = setup(true) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 76, + reason: 'swr', + aliases: [alias], + status: 'in-flight', + requestStartedAt: NOW + }) + const current = { + ...workspace, + pushTarget: { remoteName: 'origin', branchName: 'feature/old' } + } + store.setState({ worktreesByRepo: { project: [current, sibling] } }) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 76, + reason: 'swr', + aliases: [alias], + outcome: { + kind: 'found', + pr: makePR({ number: 12, headSha: 'old-head', headRefName: 'feature/old' }), + fetchedAt: NOW + } + }) + await update.mock.results[0]?.value + expect(findKnownWorktreeById(store.getState(), workspace.id, 'ssh:builder')).toBe(current) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.set' }) + ) + }) + + it('skips a coordinator clear without request-start evidence', () => { + const { store, update, alias } = setup(true) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 73, + reason: 'swr', + aliases: [alias], + outcome: { kind: 'found', pr: diverged, fetchedAt: NOW } + }) + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-post-operation-owner.test.ts b/src/renderer/src/store/slices/github-post-operation-owner.test.ts new file mode 100644 index 00000000000..305977e99f6 --- /dev/null +++ b/src/renderer/src/store/slices/github-post-operation-owner.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { createGitRemotePushPull } from './editor/actions/git-remote-push-pull' +import { createGitRemoteSync } from './editor/actions/git-remote-sync' +import { + fetchRuntimeGit, + pullRuntimeGit, + pushRuntimeGit, + fastForwardRuntimeGit, + rebaseRuntimeGitFromBase +} from '../../runtime/runtime-git-sync-client' +import { + createTestStore, + makePR, + makePRRefreshWorktree, + mockApi, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from './github-slice-test-harness' + +vi.mock('../../runtime/runtime-git-sync-client', () => ({ + fetchRuntimeGit: vi.fn(), + pullRuntimeGit: vi.fn(), + pushRuntimeGit: vi.fn(), + fastForwardRuntimeGit: vi.fn(), + rebaseRuntimeGitFromBase: vi.fn(), + getRuntimeGitUpstreamStatus: vi.fn().mockResolvedValue({ + hasUpstream: true, + ahead: 1, + behind: 0 + }) +})) + +const repo: Repo = { + id: 'project', + path: '/project', + displayName: 'Project', + badgeColor: 'blue', + addedAt: 1 +} +const branch = 'feature/review' +const operations = ['push', 'pull', 'fast-forward', 'sync', 'rebase'] as const + +describe('post-operation review refresh ownership', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + vi.clearAllMocks() + resetRemoteRuntimeMocks() + mockApi.gh.enqueuePRRefresh.mockResolvedValue(false) + mockApi.gh.refreshPRNow.mockResolvedValue({ + kind: 'found', + pr: makePR({ number: 22 }), + fetchedAt: Date.now() + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'review', + ok: true, + result: makePR({ number: 22 }) + }) + }) + afterEach(() => vi.useRealTimers()) + + it.each(['ssh:builder', 'runtime:server'] as const)( + 'refreshes %s after pushing with a same-ID copy on the same physical SSH host', + async (hostId) => { + const store = createTestStore() + const direct: Repo = { ...repo, connectionId: 'builder' } + const paired: Repo = { ...direct, executionHostId: 'runtime:server' } + const directKey = `ssh:builder::${repo.id}::${branch}` + const pairedKey = `runtime:server::${repo.id}::${branch}` + const ownerKey = hostId === 'ssh:builder' ? directKey : pairedKey + const twinKey = hostId === 'ssh:builder' ? pairedKey : directKey + const worktree = makePRRefreshWorktree({ + repoId: repo.id, + branch, + hostId: 'ssh:builder', + linkedPR: 22 + }) + const twinEntry = { data: makePR({ number: 11 }), fetchedAt: Date.now() } + store.setState({ + ...createGitRemotePushPull(store.setState, store.getState), + beginRemoteOperation: vi.fn(), + endRemoteOperation: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined), + repos: [direct, paired], + worktreesByRepo: { + [repo.id]: [ + { ...worktree, linkedPR: hostId === 'ssh:builder' ? 22 : 11 }, + { + ...worktree, + runtimeOwnerEnvironmentId: 'server', + linkedPR: hostId === 'runtime:server' ? 22 : 11 + } + ] + }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: hostId, + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]), + prCache: { [twinKey]: twinEntry } + }) + const pending = Promise.withResolvers() + vi.mocked(pushRuntimeGit).mockReturnValueOnce(pending.promise) + const runtimeTarget: RuntimeClientTarget = + hostId === 'ssh:builder' + ? { kind: 'local' } + : { kind: 'environment', environmentId: 'server' } + const task = store + .getState() + .pushBranch(worktree.id, worktree.path, false, 'builder', undefined, { runtimeTarget }) + store.setState({ + activeWorkspaceExecutionHostId: hostId === 'ssh:builder' ? 'runtime:server' : 'ssh:builder' + }) + pending.resolve() + await task + + await vi.waitFor(() => expect(store.getState().prCache[ownerKey]?.data?.number).toBe(22)) + expect(store.getState().prCache[twinKey]).toBe(twinEntry) + const transport = hostId === 'ssh:builder' ? mockApi.gh.refreshPRNow : runtimeEnvironmentCall + expect(transport).toHaveBeenCalledTimes(1) + } + ) + + it.each( + operations.flatMap((operation) => [ + { operation, selectTwin: true }, + ...(operation === 'push' ? [{ operation, selectTwin: false }] : []) + ]) + )( + '$operation refreshes its owner after switching (same-ID twin: $selectTwin)', + async ({ operation, selectTwin }) => { + // A paired SSH row from an older listing names only its physical host, not its server owner. + for (const host of ['local', 'ssh', 'runtime', 'paired-ssh'] as const) { + const store = createTestStore() + const paired = host === 'paired-ssh' + const owner: Repo = { + ...repo, + ...(host === 'ssh' || paired ? { connectionId: 'builder' } : {}), + ...(host === 'runtime' || paired ? { executionHostId: 'runtime:server' } : {}) + } + const hostId = getRepoExecutionHostId(owner) + const target: RuntimeClientTarget = + host === 'runtime' || paired + ? { kind: 'environment', environmentId: 'server' } + : { kind: 'local' } + const twinHost = host === 'local' ? 'runtime:other' : 'local' + const twin: Repo = { ...repo, executionHostId: twinHost } + const worktree = makePRRefreshWorktree({ + id: 'same-id', + repoId: repo.id, + branch, + hostId: paired ? 'ssh:builder' : hostId + }) + const ownerKey = `${host === 'local' ? '' : `${hostId}::`}${repo.id}::${branch}` + const twinKey = `${twinHost === 'local' ? '' : `${twinHost}::`}${repo.id}::${branch}` + const oldEntry = { data: makePR({ number: 11 }), fetchedAt: Date.now() } + store.setState({ + ...createGitRemotePushPull(store.setState, store.getState), + ...createGitRemoteSync(store.setState, store.getState), + beginRemoteOperation: vi.fn(), + endRemoteOperation: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined), + repos: [twin, owner], + worktreesByRepo: { [repo.id]: [{ ...worktree, hostId: twinHost }, worktree] }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: worktree.hostId, + sshConnectionStates: new Map([ + [ + 'builder', + { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]), + prCache: { [ownerKey]: oldEntry, [twinKey]: oldEntry } + }) + const pending = Promise.withResolvers() + const transport = + operation === 'push' + ? pushRuntimeGit + : operation === 'pull' + ? pullRuntimeGit + : operation === 'fast-forward' + ? fastForwardRuntimeGit + : operation === 'sync' + ? fetchRuntimeGit + : rebaseRuntimeGitFromBase + vi.mocked(transport).mockReturnValueOnce(pending.promise) + const options = { runtimeTarget: target } + const state = store.getState() + const task = + operation === 'push' + ? state.pushBranch( + worktree.id, + worktree.path, + false, + owner.connectionId ?? undefined, + undefined, + options + ) + : operation === 'pull' + ? state.pullBranch( + worktree.id, + worktree.path, + owner.connectionId ?? undefined, + undefined, + options + ) + : operation === 'fast-forward' + ? state.fastForwardBranch( + worktree.id, + worktree.path, + owner.connectionId ?? undefined, + undefined, + options + ) + : operation === 'sync' + ? state.syncBranch( + worktree.id, + worktree.path, + owner.connectionId ?? undefined, + undefined, + options + ) + : state.rebaseFromBase( + worktree.id, + worktree.path, + 'main', + owner.connectionId ?? undefined, + undefined, + options + ) + store.setState({ + activeWorktreeId: selectTwin ? worktree.id : 'unrelated-workspace', + activeWorkspaceExecutionHostId: twinHost + }) + pending.resolve() + await task + await vi.waitFor(() => expect(store.getState().prCache[ownerKey]?.data?.number).toBe(22)) + expect(store.getState().prCache[twinKey]).toBe(oldEntry) + } + } + ) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts index f81d97c2b2e..981b31cc15a 100644 --- a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts @@ -9,9 +9,24 @@ import { mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' +import type { StoreApi } from 'zustand' import type { AppState } from '../types' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +function applyStartedEvent( + store: StoreApi, + event: Parameters[0] +) { + store.getState().applyGitHubPRRefreshEvent({ + sequence: event.sequence, + reason: event.reason, + aliases: event.aliases, + status: 'in-flight', + requestStartedAt: Date.now() + }) + store.getState().applyGitHubPRRefreshEvent(event) +} + describe('createGitHubSlice.fetchPRForBranch', () => { beforeEach(() => { vi.clearAllMocks() @@ -49,7 +64,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { worktree }) - store.getState().applyGitHubPRRefreshEvent({ + applyStartedEvent(store, { sequence: 1, reason: 'swr', aliases: [ @@ -78,7 +93,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(updateWorktreeMeta).toHaveBeenCalledWith( worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { shouldApply: expect.any(Function) } + { executionHostId: 'local', shouldApply: expect.any(Function) } ) expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() }) @@ -104,7 +119,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { worktree }) - store.getState().applyGitHubPRRefreshEvent({ + applyStartedEvent(store, { sequence: 1, reason: 'swr', aliases: [ @@ -128,7 +143,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(updateWorktreeMeta).toHaveBeenCalledWith( worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { shouldApply: expect.any(Function) } + { executionHostId: 'local', shouldApply: expect.any(Function) } ) expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() }) @@ -505,7 +520,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { } as unknown as Partial) // PR-number coalescing broadcasts A’s head probe to B too; clear only diverged A, retaining B’s contained-head link. - store.getState().applyGitHubPRRefreshEvent({ + applyStartedEvent(store, { sequence: 1, reason: 'swr', aliases: [ diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 487021f3aa5..c157a55b34e 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -144,7 +144,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(updateWorktreeMeta).toHaveBeenCalledWith( worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { shouldApply: expect.any(Function) } + { executionHostId: 'local', shouldApply: expect.any(Function) } ) expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedItems).toContainEqual({ @@ -206,6 +206,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, { + executionHostId: 'local', suppressHostedReviewRefresh: true, shouldApply: expect.any(Function) } @@ -263,7 +264,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(updateWorktreeMeta).toHaveBeenCalledWith( worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { shouldApply: expect.any(Function) } + { executionHostId: 'local', shouldApply: expect.any(Function) } ) expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() }) @@ -579,7 +580,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(updateWorktreeMeta).toHaveBeenCalledWith( worktreeId, CLEARED_HOSTED_REVIEW_SELECTION_UPDATES, - { shouldApply: expect.any(Function) } + { executionHostId: 'local', shouldApply: expect.any(Function) } ) expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) expect(store.getState().prCache[`${repoId}::${branch}`]).toMatchObject({ diff --git a/src/renderer/src/store/slices/github-pr-host-scope.test.ts b/src/renderer/src/store/slices/github-pr-host-scope.test.ts new file mode 100644 index 00000000000..ee0f07ab9db --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-host-scope.test.ts @@ -0,0 +1,580 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { forgeOwnerHostIdForWorkspace } from '../../runtime/forge-credential-target' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '../github/cache-identity' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { getVisibleHostedReviewRefreshTargets } from '../github/visible-hosted-review-refresh-targets' +import { resolveChecksPanelReviewLookup } from '../../components/right-sidebar/checks-panel-review-lookup-authority' +import { createUpdateWorktreeMeta } from './worktrees/metadata/update-worktree-meta' +import { findKnownWorktreeById } from './worktrees/listing/detected-worktree-meta' +import { persistWorktreeMeta } from './worktrees/metadata/worktree-meta-persist' +import { + createTestStore, + makePR, + makePRRefreshWorktree, + mockApi, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from './github-slice-test-harness' + +vi.mock('./worktrees/metadata/worktree-meta-persist', () => ({ + persistWorktreeMeta: vi.fn().mockResolvedValue(undefined) +})) + +const localRepo: Repo = { + id: 'project', + path: '/local/project', + displayName: 'Project', + badgeColor: 'blue', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/owner/project', + remoteName: 'origin', + remoteUrl: 'https://github.com/owner/project.git' + } +} +const branch = 'feature/remote-ci' +const localPR = makePR({ number: 11, checksStatus: 'failure' }) +const remotePR = makePR({ number: 22 }) +const checks = [{ name: 'build', status: 'completed', conclusion: 'success', url: null }] + +function setup(remoteRepo: Repo) { + const store = createTestStore() + const hostId = getRepoExecutionHostId(remoteRepo) + const worktree = makePRRefreshWorktree({ + id: 'project::/worktree', + repoId: remoteRepo.id, + branch, + hostId, + linkedPR: remotePR.number + }) + store.setState({ + repos: [localRepo, remoteRepo], + worktreesByRepo: { project: [worktree] }, + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: hostId, + visibleReviewWorktreeIds: [worktree.id], + sshConnectionStates: new Map([ + ['builder', { targetId: 'builder', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + prCache: { [`project::${branch}`]: { data: localPR, fetchedAt: Date.now() } } + }) + return { store, worktree, hostId } +} + +describe('PR panel with local and remote copies of one project', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + vi.clearAllMocks() + resetRemoteRuntimeMocks() + mockApi.gh.refreshPRNow.mockReset() + mockApi.gh.refreshPRNow.mockResolvedValue({ + kind: 'found', + pr: remotePR, + fetchedAt: Date.now() + }) + mockApi.gh.prChecks.mockResolvedValue(checks) + mockApi.gh.prComments.mockResolvedValue([]) + runtimeEnvironmentCall.mockImplementation(({ method }) => ({ + id: method, + ok: true, + result: + method === 'github.prForBranch' ? remotePR : method === 'github.prChecks' ? checks : [], + _meta: { runtimeId: 'server' } + })) + }) + afterEach(() => vi.useRealTimers()) + + it.each([ + { host: 'ssh', samePath: false }, + { host: 'runtime', samePath: false }, + { host: 'ssh', samePath: true }, + { host: 'runtime', samePath: true } + ])('loads PR details and CI for $host (same path: $samePath)', async ({ host, samePath }) => { + const remoteRepo: Repo = { + ...localRepo, + path: samePath ? localRepo.path : '/remote/project', + ...(host === 'ssh' ? { connectionId: 'builder' } : { executionHostId: 'runtime:server' }) + } + const { store, worktree, hostId } = setup(remoteRepo) + if (samePath) { + store.setState({ + worktreesByRepo: { + project: [ + { ...worktree, hostId: 'local', head: 'local-head', linkedPR: localPR.number }, + worktree + ] + } + }) + } + const options = { + repoId: remoteRepo.id, + worktreeId: worktree.id, + linkedPRNumber: remotePR.number, + ...(samePath ? { repoOwnerExecutionHostId: hostId } : {}) + } + const key = getGitHubPRCacheKey( + remoteRepo.path, + remoteRepo.id, + branch, + null, + remoteRepo.connectionId, + remoteRepo.executionHostId, + true + ) + + await store.getState().fetchPRForBranch(remoteRepo.path, branch, options) + const pr = store.getState().prCache[key]?.data + expect(pr?.number).toBe(remotePR.number) + expect( + resolveChecksPanelReviewLookup({ + pr, + prCachedHasPR: pr != null, + hostedReview: null + }).state + ).toBe('found') + + await store + .getState() + .fetchPRChecks(remoteRepo.path, remotePR.number, branch, undefined, null, options) + await store.getState().fetchPRComments(remoteRepo.path, remotePR.number, options) + + expect(store.getState().prCache[key]?.data?.checksStatus).toBe('success') + expect( + store.getState().checksCache[`${hostId}::project::${prChecksCacheSuffix(remotePR.number)}`] + ?.data + ).toEqual(checks) + expect( + store.getState().commentsCache[ + `${hostId}::project::${prCommentsCacheSuffix(remotePR.number)}` + ]?.data + ).toEqual([]) + expect(store.getState().prCache[`project::${branch}`]?.data).toEqual(localPR) + }) + + it.each([false, true])( + 'refreshes the active server copy (colliding workspace ID: %s)', + async (colliding) => { + const remoteRepo: Repo = { + ...localRepo, + path: '/remote/project', + executionHostId: 'runtime:server' + } + const { store, worktree } = setup(remoteRepo) + if (colliding) { + store.setState({ + worktreesByRepo: { + project: [{ ...worktree, hostId: 'local', head: 'local-head', linkedPR: 11 }, worktree] + } + }) + } + + store.getState().enqueueGitHubPRRefresh(worktree.id, 'active', 80) + + await vi.waitFor(() => { + expect(store.getState().prCache[`runtime:server::project::${branch}`]?.data?.number).toBe( + 22 + ) + }) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(store.getState().prCache[`project::${branch}`]?.data).toEqual(localPR) + } + ) + + it.each(['enqueue', 'refresh'] as const)( + '%s preserves a server-qualified legacy SSH owner when direct SSH overlaps', + async (action) => { + const serverRepo: Repo = { + ...localRepo, + path: '/server/project', + executionHostId: 'runtime:server', + connectionId: 'builder' + } + const directRepo: Repo = { + ...serverRepo, + path: '/direct/project', + executionHostId: 'ssh:builder' + } + const { store, worktree } = setup(serverRepo) + const directKey = `ssh:builder::project::${branch}` + const directEntry = { data: localPR, fetchedAt: Date.now() } + store.setState({ + repos: [directRepo, serverRepo], + worktreesByRepo: { project: [{ ...worktree, hostId: 'ssh:builder' }] }, + activeWorkspaceExecutionHostId: 'ssh:builder', + prCache: { [directKey]: directEntry } + }) + + const refresh = () => { + if (action === 'enqueue') { + store.getState().enqueueGitHubPRRefresh(worktree.id, 'active', 80, 'runtime:server') + } else { + store.getState().refreshGitHubForWorktree(worktree.id, 'runtime:server') + } + } + refresh() + + await vi.waitFor(() => { + expect(store.getState().prCache[`runtime:server::project::${branch}`]?.data?.number).toBe( + 22 + ) + }) + expect(store.getState().prCache[directKey]).toBe(directEntry) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'server', method: 'github.prForBranch' }) + ) + + vi.clearAllMocks() + store.setState({ repos: [directRepo] }) + refresh() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(store.getState().prCache[directKey]).toBe(directEntry) + } + ) + + it.each(['ssh', 'runtime'] as const)( + 'clears only the %s copy of a linked merged PR on a fresh cache hit', + async (host) => { + const remoteRepo: Repo = { + ...localRepo, + ...(host === 'ssh' ? { connectionId: 'builder' } : { executionHostId: 'runtime:server' }) + } + const { store, worktree, hostId } = setup(remoteRepo) + const localWorktree = { ...worktree, hostId: 'local' as const } + const cachedPR = makePR({ + number: remotePR.number, + state: 'merged', + headSha: 'merged-head', + headDivergedFromMergedPRAtOid: worktree.head + }) + const remoteKey = getGitHubPRCacheKey( + remoteRepo.path, + remoteRepo.id, + branch, + null, + remoteRepo.connectionId, + remoteRepo.executionHostId, + true + ) + const updateWorktreeMeta = vi.fn(createUpdateWorktreeMeta(store.setState, store.getState)) + store.setState({ + worktreesByRepo: { project: [localWorktree, worktree] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + sortEpoch: 0, + prCache: { + ...store.getState().prCache, + [remoteKey]: { data: cachedPR, fetchedAt: Date.now() } + }, + getKnownWorktreeById: (id, ownerHostId) => + findKnownWorktreeById(store.getState(), id, ownerHostId), + updateWorktreeMeta, + fetchHostedReviewForBranch: vi.fn().mockResolvedValue(null) + }) + + expect( + await store.getState().fetchPRForBranch(remoteRepo.path, branch, { + repoId: remoteRepo.id, + repoOwnerExecutionHostId: hostId, + worktreeId: worktree.id, + linkedPRNumber: remotePR.number + }) + ).toEqual(cachedPR) + await updateWorktreeMeta.mock.results[0]?.value + + expect(findKnownWorktreeById(store.getState(), worktree.id, 'local')).toBe(localWorktree) + expect(findKnownWorktreeById(store.getState(), worktree.id, hostId)?.linkedPR).toBeNull() + expect(persistWorktreeMeta).toHaveBeenCalledWith( + host === 'runtime' ? { kind: 'environment', environmentId: 'server' } : { kind: 'local' }, + worktree.id, + expect.objectContaining({ linkedPR: null }), + hostId, + undefined + ) + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + } + ) + + it.each(['ssh', 'runtime'])( + 'checks existing PR eligibility on the same %s owner', + async (host) => { + const remoteRepo: Repo = { + ...localRepo, + ...(host === 'ssh' ? { connectionId: 'builder' } : { executionHostId: 'runtime:server' }) + } + const { store, hostId } = setup(remoteRepo) + const eligibility = { + provider: 'github', + canCreate: false, + blockedReason: 'existing_review', + nextAction: 'open_existing_review', + reviewLookupOutcome: 'found', + review: { number: remotePR.number, url: remotePR.url } + } + mockApi.hostedReview.getCreationEligibility.mockResolvedValue(eligibility) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'eligibility', + ok: true, + result: eligibility, + _meta: { runtimeId: 'server' } + }) + + expect( + await store.getState().getHostedReviewCreationEligibility({ + repoPath: remoteRepo.path, + repoId: remoteRepo.id, + repoOwnerExecutionHostId: hostId, + branch + }) + ).toEqual(eligibility) + if (host === 'runtime') { + expect(mockApi.hostedReview.getCreationEligibility).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + params: { repo: remoteRepo.id, repoId: remoteRepo.id, branch } + }) + ) + } else { + expect(mockApi.hostedReview.getCreationEligibility).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'builder', + repoOwnerExecutionHostId: hostId + }) + ) + } + } + ) + + it.each(['ssh', 'runtime', 'paired-legacy', 'paired-stamped'] as const)( + 'mutates the %s owner comment cache and preserves the same-ID twin', + async (host) => { + const remoteRepo: Repo = { + ...localRepo, + ...(host === 'ssh' ? { connectionId: 'builder' } : { executionHostId: 'runtime:server' }) + } + if (host.startsWith('paired')) { + remoteRepo.connectionId = 'builder' + } + const { store, hostId, worktree } = setup(remoteRepo) + const workspace = host.startsWith('paired') + ? { + ...worktree, + hostId: 'ssh:builder' as const, + ...(host === 'paired-stamped' ? { runtimeOwnerEnvironmentId: 'server' } : {}) + } + : worktree + store.setState({ worktreesByRepo: { project: [workspace] } }) + const comment: PRComment = { + id: 1, + author: 'reviewer', + authorAvatarUrl: '', + body: 'Review', + createdAt: '', + url: '', + threadId: 'thread', + isResolved: false, + reactionSubjectId: 'subject' + } + const reply = { ...comment, id: 2, body: 'Reply' } + const conversation = { ...comment, id: 3, body: 'Conversation' } + const options = { + repoId: remoteRepo.id, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(remoteRepo, workspace) + } + const suffix = prCommentsCacheSuffix(remotePR.number) + const localKey = `project::${suffix}` + const remoteKey = `${hostId}::project::${suffix}` + const localEntry = { data: [comment], fetchedAt: Date.now() } + store.setState({ commentsCache: { [localKey]: localEntry } }) + mockApi.gh.prComments.mockResolvedValue([comment]) + mockApi.gh.resolveReviewThread.mockResolvedValue(true) + mockApi.gh.setPRCommentReaction.mockResolvedValue(false) + mockApi.gh.addPRReviewCommentReply.mockResolvedValue({ ok: true, comment: reply }) + mockApi.gh.addIssueComment.mockResolvedValue({ ok: true, comment: conversation }) + runtimeEnvironmentCall.mockImplementation(({ method }) => ({ + id: method, + ok: true, + result: + method === 'github.prComments' + ? [comment] + : method === 'github.resolveReviewThread' + ? true + : method === 'github.setPRCommentReaction' + ? false + : { + ok: true, + comment: method === 'github.addPRReviewCommentReply' ? reply : conversation + } + })) + + await store.getState().fetchPRComments(remoteRepo.path, remotePR.number, options) + const resolving = store + .getState() + .resolveReviewThread(remoteRepo.path, remotePR.number, 'thread', true, options) + expect(store.getState().commentsCache[remoteKey]?.data?.[0].isResolved).toBe(true) + expect(await resolving).toBe(true) + const reacting = store + .getState() + .setPRCommentReaction(remoteRepo.path, remotePR.number, 'subject', '+1', true, options) + expect(store.getState().commentsCache[remoteKey]?.data?.[0].reactions).toEqual([ + { content: '+1', count: 1, viewerHasReacted: true } + ]) + expect(await reacting).toBe(false) + expect(store.getState().commentsCache[remoteKey]?.data?.[0].reactions ?? []).toEqual([]) + await store + .getState() + .addPRReviewCommentReply(remoteRepo.path, remotePR.number, comment.id, reply.body, options) + await store + .getState() + .addPRConversationComment(remoteRepo.path, remotePR.number, conversation.body, options) + expect(store.getState().commentsCache[remoteKey]?.data?.map((entry) => entry.id)).toEqual([ + 1, 2, 3 + ]) + expect(store.getState().commentsCache[localKey]).toBe(localEntry) + if (host !== 'ssh') { + expect(mockApi.gh.resolveReviewThread).not.toHaveBeenCalled() + expect(mockApi.gh.addIssueComment).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + method: 'github.resolveReviewThread' + }) + ) + } else { + expect(mockApi.gh.resolveReviewThread).toHaveBeenCalledWith( + expect.objectContaining({ + repoOwnerExecutionHostId: hostId + }) + ) + expect(mockApi.gh.addIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ + repoOwnerExecutionHostId: hostId + }) + ) + } + } + ) + + it('keeps a manual linked-PR result for a paired SSH row that predates owner stamps', async () => { + const pairedRepo: Repo = { + ...localRepo, + path: '/remote/project', + connectionId: 'builder', + executionHostId: 'runtime:server' + } + const { store, worktree } = setup(pairedRepo) + store.setState({ + worktreesByRepo: { project: [{ ...worktree, hostId: 'ssh:builder' }] }, + activeWorkspaceExecutionHostId: 'ssh:builder' + }) + + const pr = await store.getState().fetchPRForBranch(pairedRepo.path, branch, { + force: true, + repoId: pairedRepo.id, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(pairedRepo, { + ...worktree, + hostId: 'ssh:builder' + }), + worktreeId: worktree.id, + linkedPRNumber: remotePR.number, + reason: 'manual' + }) + + expect(pr?.number).toBe(remotePR.number) + expect(store.getState().prCache[`runtime:server::project::${branch}`]?.data?.number).toBe( + remotePR.number + ) + expect(store.getState().prCache[`project::${branch}`]?.data).toEqual(localPR) + }) + + it('skips passive refreshes when a host-only API cannot distinguish nested SSH repository twins', () => { + const pairedRepo: Repo = { + ...localRepo, + executionHostId: 'runtime:server', + connectionId: 'builder' + } + const { store, worktree } = setup(pairedRepo) + store.setState({ + repos: [pairedRepo, { ...pairedRepo, connectionId: 'other' }], + worktreesByRepo: { + project: [{ ...worktree, hostId: 'ssh:builder', runtimeOwnerEnvironmentId: 'server' }] + } + }) + expect(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)).toEqual([]) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('returns normal mutation failures when the selected repository owner is gone', async () => { + const { store } = setup({ ...localRepo, executionHostId: 'runtime:server' }) + const options = { repoId: localRepo.id, repoOwnerExecutionHostId: 'runtime:missing' } + await expect( + store.getState().addPRConversationComment(localRepo.path, 22, 'comment', options) + ).resolves.toMatchObject({ ok: false }) + await expect( + store.getState().addPRReviewCommentReply(localRepo.path, 22, 1, 'reply', options) + ).resolves.toMatchObject({ ok: false }) + await expect( + store.getState().setPRCommentReaction(localRepo.path, 22, 'subject', '+1', true, options) + ).resolves.toBe(false) + await expect( + store.getState().resolveReviewThread(localRepo.path, 22, 'thread', true, options) + ).resolves.toBe(false) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.addIssueComment).not.toHaveBeenCalled() + }) + + it('refuses a missing owner instead of loading the local copy', async () => { + const { store } = setup({ ...localRepo, executionHostId: 'runtime:server' }) + await expect( + store.getState().fetchPRForBranch(localRepo.path, branch, { + repoId: localRepo.id, + repoOwnerExecutionHostId: 'runtime:missing' + }) + ).rejects.toThrow('owning host') + await expect( + store.getState().getHostedReviewCreationEligibility({ + repoPath: localRepo.path, + repoId: localRepo.id, + repoOwnerExecutionHostId: 'runtime:missing', + branch + }) + ).rejects.toThrow('owning host') + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(mockApi.hostedReview.getCreationEligibility).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('keeps automatic server refresh enabled when the local copy is listed first', async () => { + const remoteRepo: Repo = { + ...localRepo, + path: '/remote/project', + executionHostId: 'runtime:server' + } + const { store } = setup(remoteRepo) + const key = getHostedReviewCacheKey( + remoteRepo.path, + branch, + null, + remoteRepo.id, + null, + remoteRepo.executionHostId, + true + ) + const targets = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState) + + expect(targets.map((target) => target.key)).toContain(key) + await targets.find((target) => target.key === key)?.refresh() + expect(store.getState().prCache[`runtime:server::project::${branch}`]?.data?.number).toBe(22) + expect(store.getState().prCache[`project::${branch}`]?.data).toEqual(localPR) + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-refresh-linked-scope-lifetime.test.ts b/src/renderer/src/store/slices/github-pr-refresh-linked-scope-lifetime.test.ts new file mode 100644 index 00000000000..a510a72c7e7 --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-linked-scope-lifetime.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' +import { + _clearGitHubPRRefreshStartedEntriesForTest, + prRefreshStartedHostedReviewEntries, + prRefreshStartedLinkedReviewScopes +} from '../github/request-coordination' +import { + buildGitHubPRRefreshStateClearToken, + getGitHubPRRefreshStateExpiryAt +} from '../github/pr-refresh-state' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { + createTestStore, + installLinkedPRClearStub, + makePR, + makePRRefreshWorktree +} from './github-slice-test-harness' + +function setup(population: number) { + const store = createTestStore() + const repoId = 'repo-1' + const repoPath = '/repo' + const first = makePRRefreshWorktree({ + id: 'wt-0', + repoId, + branch: 'feature/0', + head: 'shared-head', + linkedPR: 12 + }) + const worktrees = [ + first, + ...Array.from({ length: population - 1 }, (_, index) => ({ + ...first, + id: `wt-${index + 1}`, + branch: `feature/${index + 1}` + })) + ] + const update = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch: first.branch, + worktree: first + }) + store.setState({ + worktreesByRepo: { [repoId]: worktrees }, + hostedReviewCache: Object.fromEntries( + worktrees.map((worktree) => [ + getHostedReviewCacheKey(repoPath, worktree.branch, null, repoId), + { data: null, fetchedAt: Date.now() } + ]) + ) + }) + const aliases = worktrees.map((worktree) => ({ + repoId, + repoPath, + branch: worktree.branch, + cacheKey: `${repoId}::${worktree.branch}`, + worktreeId: worktree.id, + linkedPRNumber: 12, + currentHeadOid: worktree.head + })) + const start = (sequence = 1, requestAliases = aliases) => + store.getState().applyGitHubPRRefreshEvent({ + sequence, + reason: 'manual', + aliases: requestAliases, + status: 'in-flight', + requestStartedAt: Date.now() + }) + const finish = ( + sequence = 1, + outcome: PRRefreshOutcome = { + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-head', + headDivergedFromMergedPRAtOid: 'shared-head' + }), + fetchedAt: Date.now() + } + ) => store.getState().applyGitHubPRRefreshEvent({ sequence, reason: 'manual', aliases, outcome }) + return { store, update, aliases, repoId, start, finish } +} + +describe('linked review scope lifetime', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date', 'performance'] }) + vi.setSystemTime(Date.UTC(2026, 0, 1)) + _clearGitHubPRRefreshStartedEntriesForTest() + }) + + afterEach(() => { + _clearGitHubPRRefreshStartedEntriesForTest() + vi.useRealTimers() + }) + + it('clears every eligible link in a refresh exceeding the 128-entry cache limit', () => { + const population = 129 + const { store, update, repoId, start, finish } = setup(population) + start() + expect(prRefreshStartedHostedReviewEntries.size).toBe(128) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(population) + finish() + + expect(update).toHaveBeenCalledTimes(population) + expect(store.getState().worktreesByRepo[repoId].every((row) => row.linkedPR === null)).toBe( + true + ) + expect(prRefreshStartedHostedReviewEntries.size).toBe(0) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + finish() + expect(update).toHaveBeenCalledTimes(population) + }) + + it.each(['paused', 'skipped'] as const)('retires all scopes when a refresh is %s', (status) => { + const { store, update, aliases, start, finish } = setup(512) + start() + store.getState().applyGitHubPRRefreshEvent({ + sequence: 2, + reason: 'manual', + aliases, + ...(status === 'paused' + ? { status, pausedUntil: Date.now() + 60_000, skippedReason: 'rate-limit' as const } + : { status, skippedReason: 'fresh' as const }) + }) + expect(prRefreshStartedHostedReviewEntries.size).toBe(0) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + finish() + expect(update).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'no-pr', fetchedAt: 1 }, + { kind: 'upstream-error', errorType: 'network', message: 'Offline', fetchedAt: 1 } + ] satisfies PRRefreshOutcome[])('releases scopes after a $kind outcome', (outcome) => { + const { start, finish, update } = setup(129) + start() + finish(1, outcome) + expect(prRefreshStartedHostedReviewEntries.size).toBe(0) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + expect(update).not.toHaveBeenCalled() + }) + + it('keeps successor scopes when a superseded refresh completes late', () => { + const { start, finish, update } = setup(512) + start(1) + start(2) + finish(1) + expect(update).not.toHaveBeenCalled() + expect(prRefreshStartedLinkedReviewScopes.size).toBe(512) + finish(2) + expect(update).toHaveBeenCalledTimes(512) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + }) + + it('preserves expiry order when a scope is superseded', () => { + const { store, aliases, start } = setup(2) + start(1, [aliases[0]]) + vi.advanceTimersByTime(60_000) + start(2, [aliases[1]]) + vi.advanceTimersByTime(30_000) + start(3, [aliases[0]]) + vi.advanceTimersByTime(90_001) + store.getState().refreshAllGitHub() + expect([...prRefreshStartedLinkedReviewScopes.keys()]).toEqual([aliases[0].cacheKey]) + vi.advanceTimersByTime(30_000) + store.getState().refreshAllGitHub() + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + }) + + it.each(['sweep', 'event', 'token'] as const)( + 'retires abandoned scopes through %s cleanup', + (cleanup) => { + const { store, aliases, start, finish, update } = setup(1) + start() + const key = aliases[0].cacheKey + const state = store.getState() + const refresh = state.prRefreshStates[key] + const expiry = getGitHubPRRefreshStateExpiryAt(refresh) + const token = buildGitHubPRRefreshStateClearToken(refresh, state.prRefreshSequences, key) + if (expiry === null || token === null) { + throw new Error('Expected an active refresh') + } + vi.advanceTimersByTime(expiry - Date.now() + 1) + if (cleanup === 'sweep') { + store.getState().refreshAllGitHub() + } else if (cleanup === 'token') { + store.getState().expireGitHubPRRefreshState(key, token) + } else { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 2, + reason: 'visible', + aliases: [{ cacheKey: 'unrelated', repoPath: '/other', branch: 'other' }], + status: 'queued' + }) + } + expect(prRefreshStartedHostedReviewEntries.size).toBe(0) + expect(prRefreshStartedLinkedReviewScopes.size).toBe(0) + finish() + expect(update).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index d1257381aeb..d71513dfbe9 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -179,6 +179,57 @@ describe('GitHub PR refresh owner-host routing', () => { } ) + it.each(['activation', 'checks'] as const)( + 'routes paired SSH %s refresh through its explicit runtime owner despite a direct twin', + async (trigger) => { + const store = createTestStore() + const pairedRepo = makeRepo({ + id: 'repo', + path: '/paired/repo', + executionHostId: 'runtime:server', + connectionId: 'builder' + }) + const workspace = { + ...makeWorktree('repo', 'feature/paired', 'wt-paired'), + hostId: 'ssh:builder' as const, + runtimeOwnerEnvironmentId: 'server' + } + seed(store, { + activeWorktreeId: workspace.id, + activeWorkspaceExecutionHostId: 'ssh:builder', + repos: [ + { ...pairedRepo, path: '/direct/repo', executionHostId: 'ssh:builder' }, + pairedRepo + ], + worktreesByRepo: { repo: [workspace] } + }) + runtimeEnvironmentCall.mockResolvedValue({ ok: true, result: null }) + + if (trigger === 'activation') { + store.getState().refreshGitHubForWorktreeIfStale(workspace.id) + } else { + store.getState().refreshGitHubForWorktree(workspace.id) + } + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server', + method: 'github.prForBranch', + params: expect.objectContaining({ repo: 'repo', branch: 'feature/paired' }) + }) + ) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + + runtimeEnvironmentCall.mockClear() + store.setState({ repos: [{ ...pairedRepo, connectionId: 'another-machine' }] }) + store.getState().refreshGitHubForWorktree(workspace.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(enqueuePRRefresh).not.toHaveBeenCalled() + } + ) + it('routes PR refresh to the runtime repo id when an SSH row shares its path', async () => { runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'rpc-shared-path', diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index f7db58eac8c..963f905e42f 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -7,6 +7,7 @@ import { isExpiredActivePRRefreshState } from '../github/pr-refresh-state' import { evictStaleEntries } from '../github/cache-policy' +import { deletePRRefreshStartedEntry } from '../github/pr-result-cache' import { persistGitHubCacheChanges } from '../github/cache-persistence' import { createProjectActions } from '../github/project-actions' import { createProjectRowActions } from '../github/project-row-actions' @@ -75,6 +76,7 @@ export const createGitHubSlice: StateCreator = ( } const nextStates = { ...s.prRefreshStates } delete nextStates[cacheKey] + deletePRRefreshStartedEntry(token.sequence, cacheKey) return { prRefreshStates: nextStates } }) }, diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts index 2b60699067e..4ca42f90702 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -105,7 +105,7 @@ export function findHostedReviewRepoByPath( (candidate) => getRepoExecutionHostId(candidate) === repoOwnerExecutionHostId ) } - return matches?.[0] + return matches?.find((candidate) => candidate.path === repoPath) ?? matches?.[0] } export function findHostedReviewRepoForFetch( @@ -221,8 +221,13 @@ export function withHostedReviewCacheEntry( /** Known runtime repos use owner server; local/SSH/unknown repos use app IPC. Focus cannot infer ownership. */ export function hostedReviewRepoOwnerTarget( - repo: Pick | undefined + repo: Pick | null | undefined ): RuntimeClientTarget { + if (repo === null) { + throw new Error( + 'The repository is no longer available on its owning host. Reconnect and retry.' + ) + } return ( (repo ? ipcOrRuntimeTargetForHostId(getRepoExecutionHostId(repo)) : null) ?? { kind: 'local' } ) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index b6aa2c56d70..cf95561a87c 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -71,10 +71,11 @@ export const createHostedReviewSlice: StateCreator { - const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) + const repo = findHostedReviewRepoForFetch(get().repos, args.repoPath, args) const target = hostedReviewRepoOwnerTarget(repo) if (target.kind === 'environment') { - const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args + const { repoOwnerExecutionHostId: _ownerHostId, ...requestArgs } = args + const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = requestArgs void _repoPath return callRuntimeRpc( target, @@ -174,14 +175,8 @@ export const createHostedReviewSlice: StateCreator { expect(store.getState().prCache[legacyPathPRCacheKey]).toBeUndefined() expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'pr-branch', { repoId: 'repo1', + repoOwnerExecutionHostId: 'local', linkedGitHubPR: null, linkedGitLabMR: null, linkedBitbucketPR: null, diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 63fa7a13ad8..bc9a80a6ccf 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -36,7 +36,9 @@ import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc- import { runtimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' -import { findRepoForHost } from '../../repo-host-identity' +import { hasUniqueIndexedRepositoryHost } from '@/lib/repository-owner-index' +import { resolveReviewRepository } from '@/lib/review-owner' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' export function createUpdateWorktreeMeta( set: WorktreeSliceSet, get: WorktreeSliceGet @@ -138,12 +140,10 @@ export function createUpdateWorktreeMeta( const shouldRefreshHostedReview = Boolean( worktreeForUpdate && hasChangedHostedReviewLinkUpdates(normalizedUpdates, worktreeForUpdate) ) - const reviewRepo = shouldRefreshHostedReview - ? (findRepoForHost(get().repos, worktreeForUpdate?.repoId ?? '', { - hostId: executionHostId, - settings: get().settings - }) ?? undefined) - : undefined + const reviewRepo = + shouldRefreshHostedReview && worktreeForUpdate + ? (resolveReviewRepository(get().repos, worktreeForUpdate) ?? undefined) + : undefined const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '') // Why: bump lastActivityAt on comment edits so the time-decay sort doesn't drop a just-touched worktree. @@ -215,13 +215,18 @@ export function createUpdateWorktreeMeta( if ( !options?.suppressHostedReviewRefresh && reviewRepo && + hasUniqueIndexedRepositoryHost( + get().repos, + reviewRepo.id, + getRepoExecutionHostId(reviewRepo) + ) && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function' ) { // Why: refetch against post-update links so a cache entry from the previous provider link can't keep showing the removed review. void get().fetchHostedReviewForBranch(reviewRepo.path, reviewBranch, { repoId: reviewRepo.id, - repoOwnerExecutionHostId: executionHostId ?? worktreeForUpdate?.hostId, + repoOwnerExecutionHostId: getRepoExecutionHostId(reviewRepo), ...getWorkspaceReviewRefreshHints(targetEnriched, worktreeForUpdate), force: true }) diff --git a/src/shared/check-run-details-owner-scope.ts b/src/shared/check-run-details-owner-scope.ts new file mode 100644 index 00000000000..8491ab359db --- /dev/null +++ b/src/shared/check-run-details-owner-scope.ts @@ -0,0 +1,16 @@ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from './execution-host' + +export function checkRunDetailsOwnerScope( + hostId: ExecutionHostId, + runtimeEnvironmentId?: string | null +): string | null { + const runtimeId = runtimeEnvironmentId?.trim() + const runtimeHostId = runtimeId ? toRuntimeExecutionHostId(runtimeId) : null + // A paired server and direct SSH connection to the same machine use separate transports. + const scope = runtimeHostId && runtimeHostId !== hostId ? `${hostId}>${runtimeHostId}` : hostId + return scope === LOCAL_EXECUTION_HOST_ID ? null : scope +} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index c9792e241f5..b2b632f157f 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -241,6 +241,7 @@ export type HostedReviewCreationEligibility = { export type HostedReviewCreationEligibilityArgs = { repoPath: string repoId?: string + repoOwnerExecutionHostId?: string worktreePath?: string connectionId?: string | null branch: string diff --git a/src/shared/workspace-layout/workspace-layout-load-check-details.test.ts b/src/shared/workspace-layout/workspace-layout-load-check-details.test.ts new file mode 100644 index 00000000000..c4e7b5a86c9 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-load-check-details.test.ts @@ -0,0 +1,102 @@ +import { expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../constants' +import type { ExecutionHostId } from '../execution-host' +import type { Tab } from '../tab-types' +import type { LayoutEditorView } from './layout-editor-view' +import { loadWorkspace } from './workspace-layout-load-workspace' +import { nameBasedLoadContext } from './workspace-layout-minted-ids' + +vi.mock('../layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const key = 'wt-checks' + +function checkTab(id: string, executionHostId: ExecutionHostId): Tab { + return { + id, + entityId: `${key}::check-details::${id}`, + worktreeId: key, + groupId: 'g1', + contentType: 'check-details', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + executionHostId + } +} + +function load(entries: Tab[], hostId: ExecutionHostId) { + const views: Record = Object.fromEntries( + entries.map((entry) => [ + entry.id, + { + view: { mode: 'check-details' }, + file: { filePath: entry.entityId, relativePath: entry.label, language: 'plaintext' } + } + ]) + ) + return () => + loadWorkspace({ + session: { ...getDefaultWorkspaceSession(), unifiedTabs: { [key]: entries } }, + hostId, + key, + terminalHomes: new Map(), + context: { ...nameBasedLoadContext(), editorViewsOf: () => views }, + view: { + activeRepoId: null, + activeWorktreeId: key, + activeTabId: null, + groups: {}, + lastFocusedAt: {}, + panes: {}, + editorDrafts: {} + }, + facts: { tabLabels: {}, terminalRows: {}, scrollback: {}, browserTabs: {} }, + layouts: new Map(), + canonicalTabIdBySubsumedTabId: new Map() + }) +} + +it.each(['local', 'ssh:builder'] as const)( + 'bounds stored tab ID reads on repeated %s check-tab reconstruction', + (hostId) => { + const count = 128 + let idReads = 0 + const entries = Array.from({ length: count }, (_, index) => { + const id = `check-${index}` + return { + ...checkTab(id, hostId), + get id() { + idReads++ + return id + } + } + }) + const reconstruct = load(entries, hostId) + for (let attempt = 0; attempt < 2; attempt++) { + idReads = 0 + const workspace = reconstruct() + expect(workspace.tabs).toHaveLength(count) + expect(idReads).toBeLessThan(count * 20) + const scope = hostId === 'local' ? '' : `@${hostId}` + expect(workspace.tabs[count - 1]?.entityId).toBe( + `${key}::check-details${scope}::check-${count - 1}` + ) + expect(workspace.editorViews?.['check-127']?.file.filePath).toBe( + workspace.tabs[count - 1]?.entityId + ) + } + } +) + +it('keeps the first stored owner when check-tab IDs repeat', () => { + const workspace = load( + [checkTab('duplicate', 'ssh:builder'), checkTab('duplicate', 'runtime:other')], + 'local' + )() + expect(workspace.tabs).toMatchObject([ + { id: 'duplicate', entityId: `${key}::check-details@ssh:builder::duplicate` } + ]) + expect(workspace.editorViews?.duplicate?.file.filePath).toBe(workspace.tabs[0]?.entityId) +}) diff --git a/src/shared/workspace-layout/workspace-layout-load-workspace.ts b/src/shared/workspace-layout/workspace-layout-load-workspace.ts index 658852bdbb9..06d65ef0f54 100644 --- a/src/shared/workspace-layout/workspace-layout-load-workspace.ts +++ b/src/shared/workspace-layout/workspace-layout-load-workspace.ts @@ -1,4 +1,5 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../layout-owner-switch' +import { checkRunDetailsOwnerScope } from '../check-run-details-owner-scope' import type { Tab } from '../tab-types' import { normalizeTerminalLayoutPtyOwnership } from '../terminal-layout-pty-ownership' import { sanitizeTerminalLayoutPaneTitles } from '../terminal-pane-title-sanitization' @@ -255,6 +256,32 @@ export function loadWorkspace(args: WorkspaceLoadArgs): WorkspaceLayout { } const editorViews = loadEditorViews(args, tabs) if (editorViews) { + const storedTabById = new Map() + for (const entry of session.unifiedTabs?.[key] ?? []) { + if (!storedTabById.has(entry.id)) { + storedTabById.set(entry.id, entry) + } + } + for (const tab of tabs) { + const editorView = editorViews[tab.id] + const prefix = `${worktreeId}::check-details::` + if (tab.kind !== 'check-details' || !editorView || !tab.entityId.startsWith(prefix)) { + continue + } + const entry = storedTabById.get(tab.id) + const scope = checkRunDetailsOwnerScope( + entry?.executionHostId ?? args.hostId, + editorView.file.runtimeEnvironmentId + ) + if (scope) { + // Keep tab IDs stable so group order, active selection and recent tabs still name the same tab. + tab.entityId = `${worktreeId}::check-details@${scope}::${tab.entityId.slice(prefix.length)}` + editorViews[tab.id] = { + ...editorView, + file: { ...editorView.file, filePath: tab.entityId } + } + } + } workspace.editorViews = editorViews } return workspace diff --git a/tests/e2e/claude-agent-row-view.unit.test.ts b/tests/e2e/claude-agent-row-view.unit.test.ts index 932d7143b8c..fa33e3b2c9c 100644 --- a/tests/e2e/claude-agent-row-view.unit.test.ts +++ b/tests/e2e/claude-agent-row-view.unit.test.ts @@ -447,6 +447,7 @@ it.each( }) rows('compact', child('restored')) await act(async () => {}) + expect(visits.worktrees).toBeGreaterThanOrEqual(population) visits.worktrees = 0 visits.repos = 0 await click(2) @@ -461,12 +462,9 @@ it.each( takeAndSettle() }) } - // Count real catalog reads before observation-driven renders; R stays fixed at one. - const passBound = fallback ? 8 : 6 - expect(visits.worktrees).toBeGreaterThanOrEqual(population) - expect(visits.worktrees).toBeLessThanOrEqual(passBound * population) - expect(visits.repos).toBeGreaterThan(0) - expect(visits.repos).toBeLessThanOrEqual(passBound) + // Owner indexes are warm; clicks must not rescan either catalog. + expect(visits.worktrees).toBe(0) + expect(visits.repos).toBe(0) expect(requests).toHaveLength(fallback ? 2 : 1) expect(bridge.receive({ ...identity(), action: 'take' })).toEqual({}) expect(mocks.info).not.toHaveBeenCalled()