From 5c7c4930421aca419feb290c36905c19cdb10e2a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 09:17:11 -0700 Subject: [PATCH] fix(sources): GitHub Projects boards read from the task page's source host (#27270) * fix(sources): GitHub Projects boards read from the task page's source host The Projects board, its project picker and unmatched-row edits picked their host from the Active Server setting on their own, while the Tasks header named the selected repositories' hosts. The board now takes the same row-less source host that Linear and Jira use, converted through getTaskSourceRuntimeSettings, and the header names that host in Projects mode. fetchProjectViewTable takes the source explicitly instead of reading settings. The ownership table marks the four board-level GitHub Projects reads as scope-picked. * fix(github-projects): an open unmatched-row dialog keeps writing to its row's host The dialog took the board's live source, so a source change while it was open sent its comment and field edits to a different host than the one the row was loaded from. It now routes by the source encoded in its row's cache key, like the row field edits already do. --------- Co-authored-by: m4air --- config/owner-routing-baseline.txt | 1 - .../github-project/ProjectPicker.tsx | 20 ++++++--- .../github-project/ProjectViewToolbar.tsx | 4 +- .../github-project/ProjectViewWrapper.tsx | 35 ++++++++++++--- .../github-project/project-picker-runtime.ts | 15 ++++--- .../github-project/useProjectPickerBrowse.ts | 5 ++- .../github-project/useProjectViewTable.ts | 38 ++++++---------- .../components/task-page-source-context.tsx | 22 +++++++++- ...page-task-source-host-availability.test.ts | 34 +++++++++++++- .../src/components/task-page/Content.tsx | 6 ++- .../src/components/task-page/SourceBar.tsx | 19 +++++++- .../components/task-source-context-summary.ts | 2 +- .../src/store/github/project-actions.ts | 6 +-- .../provider-request-coalescing.test.ts | 8 ++-- src/renderer/src/store/github/slice-types.ts | 3 ++ ...ithub-project-request-coordination.test.ts | 26 +++++------ .../slices/github-project-view-tables.test.ts | 44 ++++++++++--------- ...ithub-provider-request-concurrency.test.ts | 2 +- src/shared/host-operation-ownership.test.ts | 9 ++++ src/shared/host-operation-ownership.ts | 15 ++++++- 20 files changed, 219 insertions(+), 95 deletions(-) diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 9742a9aa35e..7f4a1304258 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -22,7 +22,6 @@ 4 src/renderer/src/components/github-project/project-picker-runtime.ts 1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx 1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx -2 src/renderer/src/components/github-project/useProjectViewTable.ts 1 src/renderer/src/components/github/github-rate-limit-display.tsx 4 src/renderer/src/components/github/github-work-item-edit-mutations.ts 1 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 838348cbb5b..7b1100fcc3d 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -27,7 +27,8 @@ import { parseProjectInput } from './project-picker-input' import { getProjectPickerBrowseHost, listProjectViewsForRuntime, - resolveProjectRefForRuntime + resolveProjectRefForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' import type { ProjectPickerChoice, ResolvedProjectSelection } from './project-picker-selection' import { useProjectPickerBrowse } from './useProjectPickerBrowse' @@ -45,6 +46,7 @@ type Props = { title?: string } | null onSelect: (selection: ResolvedProjectSelection) => void + sourceSettings: ProjectSourceSettings } const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { @@ -54,7 +56,11 @@ const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { activeProject: null } -export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element { +export default function ProjectPicker({ + activeProject, + onSelect, + sourceSettings +}: Props): React.JSX.Element { const settings = useAppStore((state) => state.settings) const updateSettings = useAppStore((state) => state.updateSettings) const mountedRef = useMountedRef() @@ -71,7 +77,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [viewList, setViewList] = useState([]) const [viewLoading, setViewLoading] = useState(false) const browseHost = getProjectPickerBrowseHost(activeProject ?? projectSettings.activeProject) - const browse = useProjectPickerBrowse(settings, browseHost) + const browse = useProjectPickerBrowse(sourceSettings, browseHost) const { loadBrowse } = browse useEffect(() => { @@ -143,7 +149,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setViewPickFor(selection) setViewLoading(true) try { - const result = await listProjectViewsForRuntime(settings, { + const result = await listProjectViewsForRuntime(sourceSettings, { owner: choice.owner, ownerType: choice.ownerType, projectNumber: choice.number, @@ -179,7 +185,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React } } }, - [commitSelection, mountedRef, projectSettings.lastViewByProject, settings] + [commitSelection, mountedRef, projectSettings.lastViewByProject, sourceSettings] ) const handlePaste = useCallback(async () => { @@ -196,7 +202,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteError(null) setPasteBusy(true) try { - const result = await resolveProjectRefForRuntime(settings, input, parsed.host) + const result = await resolveProjectRefForRuntime(sourceSettings, input, parsed.host) if (!mountedRef.current) { return } @@ -218,7 +224,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteBusy(false) } } - }, [handleChooseProject, mountedRef, pasteInput, settings]) + }, [handleChooseProject, mountedRef, pasteInput, sourceSettings]) const filteredBrowse = useMemo( () => diff --git a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx index d03489d7389..c9593082915 100644 --- a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx +++ b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx @@ -19,7 +19,8 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl loading, viewId, doFetch, - setAppliedQueryByView + setAppliedQueryByView, + sourceSettings } = tableState const selectedViewUrl = table ? `${table.project.url}/views/${table.selectedView.number ?? ''}` @@ -58,6 +59,7 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl : null } onSelect={(selection) => void doFetch(selection, true)} + sourceSettings={sourceSettings} /> {currentProjectViewKey ? ( } +type Props = { + selectedRepoIds: ReadonlySet + /** Row-less source host (shared with Linear and Jira): the board, picker and unmatched rows read from it. */ + sourceHostId: ExecutionHostId +} -export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { - const tableState = useProjectViewTable(selectedRepoIds) +export default function ProjectViewWrapper({ + selectedRepoIds, + sourceHostId +}: Props): React.JSX.Element { + const sourceSettings = useMemo( + () => getTaskSourceRuntimeSettings({ hostId: sourceHostId }), + [sourceHostId] + ) + const tableState = useProjectViewTable(selectedRepoIds, sourceSettings) const rowActions = useProjectRowActions({ table: tableState.table, currentCacheKey: tableState.currentCacheKey, selectedRepoIds }) const addRepo = useAppStore((state) => state.addRepo) + const slugOrigin = rowActions.missingDialogs.slugDialog?.origin ?? null + const slugCacheKey = slugOrigin?.cacheKey ?? null + // Why: an open dialog keeps writing to the host its row was loaded from, even if the board's source moves. + const slugSourceSettings = useMemo( + () => (slugCacheKey ? settingsForProjectViewCacheKey(null, slugCacheKey) : null), + [slugCacheKey] + ) return (
@@ -44,8 +65,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS ) : null} rowActions.setSlugDialog(null)} /> ) if (visibleTable.selectedView.layout === 'ROADMAP_LAYOUT') { diff --git a/src/renderer/src/components/github-project/project-picker-runtime.ts b/src/renderer/src/components/github-project/project-picker-runtime.ts index af91cc37820..b3efed48def 100644 --- a/src/renderer/src/components/github-project/project-picker-runtime.ts +++ b/src/renderer/src/components/github-project/project-picker-runtime.ts @@ -1,4 +1,5 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { githubProjectHost } from '../../../../shared/github/project-identity' import type { GitHubProjectOwnerType } from '../../../../shared/github/project-types' import type { @@ -7,9 +8,13 @@ import type { ResolveProjectRefResult } from '../../../../shared/github/project-result-types' -type RuntimeSettings = Parameters[0] +/** The board's row-less source host, from `getTaskSourceRuntimeSettings`; never the focused server. */ +export type ProjectSourceSettings = Pick -export function getProjectPickerRuntimeScope(settings: RuntimeSettings, host: string): string { +export function getProjectPickerRuntimeScope( + settings: ProjectSourceSettings, + host: string +): string { const target = getActiveRuntimeTarget(settings) const runtimeScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' return `${runtimeScope}\0${host.toLowerCase()}` @@ -20,7 +25,7 @@ export function getProjectPickerBrowseHost(activeProject: { host?: string } | nu } export async function listAccessibleProjectsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, host: string ): Promise { const target = getActiveRuntimeTarget(settings) @@ -33,7 +38,7 @@ export async function listAccessibleProjectsForRuntime( } export async function listProjectViewsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, args: { owner: string ownerType: GitHubProjectOwnerType @@ -50,7 +55,7 @@ export async function listProjectViewsForRuntime( } export async function resolveProjectRefForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, input: string, host?: string ): Promise { diff --git a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts index b29bd8ef756..0c49c637002 100644 --- a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts +++ b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts @@ -9,11 +9,12 @@ import { } from './project-picker-browse-cache' import { getProjectPickerRuntimeScope, - listAccessibleProjectsForRuntime + listAccessibleProjectsForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' export function useProjectPickerBrowse( - settings: Parameters[0], + settings: ProjectSourceSettings, browseHost: string ): { browseProjects: GitHubProjectSummary[] diff --git a/src/renderer/src/components/github-project/useProjectViewTable.ts b/src/renderer/src/components/github-project/useProjectViewTable.ts index 2649dbe1996..5fbd9ee5f08 100644 --- a/src/renderer/src/components/github-project/useProjectViewTable.ts +++ b/src/renderer/src/components/github-project/useProjectViewTable.ts @@ -2,9 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' -import { projectViewCacheKey } from '@/store/github/cache-identity' +import { projectViewCacheKey, projectViewSourceScope } from '@/store/github/cache-identity' import type { GitHubProjectSettings, GitHubProjectTable, @@ -12,14 +11,14 @@ import type { } from '../../../../shared/github/project-types' import type { GetProjectViewTableResult, - GitHubProjectViewError, - ListProjectViewsResult + GitHubProjectViewError } from '../../../../shared/github/project-result-types' import { githubProjectHost, githubProjectIdentityKey } from '../../../../shared/github/project-identity' import type { ResolvedProjectSelection } from './project-picker-selection' +import { listProjectViewsForRuntime, type ProjectSourceSettings } from './project-picker-runtime' import { filterProjectTableRowsBySelectedRepos } from './project-row-filtering' import { getNextVisibleProjectTableCache, @@ -29,21 +28,10 @@ import { } from './project-visible-table-cache' import { useRepoSlugIndex } from '@/lib/repo-slug-index' -type Settings = Parameters[0] - -async function listProjectViewsForRuntime( - settings: Settings, - args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number; host?: string } -): Promise { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'github.project.listViews', args, { - timeoutMs: 30_000 - }) - : window.api.gh.listProjectViews(args) -} - -export function useProjectViewTable(selectedRepoIds: ReadonlySet) { +export function useProjectViewTable( + selectedRepoIds: ReadonlySet, + sourceSettings: ProjectSourceSettings +) { const settings = useAppStore((state) => state.settings) const projectViewCache = useAppStore((state) => state.projectViewCache) const fetchProjectViewTable = useAppStore((state) => state.fetchProjectViewTable) @@ -51,8 +39,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {} const { lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() - const target = getActiveRuntimeTarget(settings) - const sourceScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' + const sourceScope = projectViewSourceScope(sourceSettings) const [loading, setLoading] = useState(false) const [error, setError] = useState<{ error: GitHubProjectViewError; totalCount?: number } | null>( null @@ -71,6 +58,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { setError(null) try { const result: GetProjectViewTableResult = await fetchProjectViewTable( + sourceSettings, { owner: selection.owner, ownerType: selection.ownerType, @@ -93,7 +81,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { } } }, - [fetchProjectViewTable, mountedRef] + [fetchProjectViewTable, mountedRef, sourceSettings] ) const projectIdentity = activeProject ? githubProjectIdentityKey(activeProject) : null @@ -143,7 +131,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return } let cancelled = false - void listProjectViewsForRuntime(settings, { + void listProjectViewsForRuntime(sourceSettings, { owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, @@ -164,7 +152,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return () => { cancelled = true } - }, [activeProject, settings, sourceScope, viewListByProject]) + }, [activeProject, sourceSettings, sourceScope, viewListByProject]) const selectedRepoFingerprint = useMemo( () => getSelectedRepoFingerprint(selectedRepoIds), @@ -247,7 +235,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { ) return { - settings, + sourceSettings, activeProject, sourceScope, viewId, diff --git a/src/renderer/src/components/task-page-source-context.tsx b/src/renderer/src/components/task-page-source-context.tsx index 469f6dd6c73..260e1dcae63 100644 --- a/src/renderer/src/components/task-page-source-context.tsx +++ b/src/renderer/src/components/task-page-source-context.tsx @@ -12,7 +12,11 @@ import { import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { getRepoExecutionHostId } from '../../../shared/execution-host' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import type { TaskSourceHostAvailability } from './task-source-context-summary' +import { + getAccountBackedTaskSourceSummary, + type TaskSourceContextSummary, + type TaskSourceHostAvailability +} from './task-source-context-summary' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { cn } from '@/lib/utils' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' @@ -134,6 +138,22 @@ export function getTaskSourceHostAvailabilityForHost( status: host.connectionStatus } } +/** Projects boards have no source row: name the shared row-less host, not the selected repos. */ +export function getGitHubProjectSourceSummary(args: { + providerLabel: string + hostId: TaskSourceContext['hostId'] + host: ExecutionHostRegistryEntry | null | undefined + hostLabelById: ReadonlyMap +}): TaskSourceContextSummary { + const availability = getTaskSourceHostAvailabilityForHost(args.host, args.hostId) + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: null, + accountHostId: args.hostId, + hostLabelById: args.hostLabelById, + // Why: boards need no task-source capability, so only host health applies. + hostAvailability: availability && !availability.reason ? [availability] : [] + }) +} export function getTaskPageRepoCacheInput(repo: Repo): { id: string path: string diff --git a/src/renderer/src/components/task-page-task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts index 3fe0261beaf..c3dde4bb742 100644 --- a/src/renderer/src/components/task-page-task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context' +import { + getGitHubProjectSourceSummary, + getTaskSourceHostAvailabilityForHost +} from './task-page-source-context' // Why: a complete typed entry keeps these cases honest when the registry shape changes. function hostEntry( @@ -80,3 +83,32 @@ describe('getTaskSourceHostAvailabilityForHost', () => { }) }) }) + +describe('getGitHubProjectSourceSummary', () => { + const runtimeHost = (overrides: Partial) => + hostEntry({ id: 'runtime:env-1', kind: 'runtime', label: 'Build server', ...overrides }) + + it('names the board host, which a capability check alone never marks unavailable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ health: 'available' }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toBe('GitHub · Build server · Current account') + }) + + it('says when the board host is unreachable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ + health: 'disconnected', + capabilities: [TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY] + }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toContain('Build server · ') + expect(summary.label).not.toBe('GitHub · Build server · Current account') + }) +}) diff --git a/src/renderer/src/components/task-page/Content.tsx b/src/renderer/src/components/task-page/Content.tsx index ebe40e3ff28..9688056e343 100644 --- a/src/renderer/src/components/task-page/Content.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -20,6 +20,7 @@ export function TaskPageContent({ dialogWorkItem, dialogRepoPath, dialogSourceContext, + accountBackedTaskSourceHostId, setDialogWorkItem, handleDialogReviewRequestsChange, closeTaskDetailPage, @@ -59,7 +60,10 @@ export function TaskPageContent({ ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? ( // Why: bottom of the joined GitHub list card — flush under the filter diff --git a/src/renderer/src/components/task-page/SourceBar.tsx b/src/renderer/src/components/task-page/SourceBar.tsx index 8bcedea82a5..83812b3ed8b 100644 --- a/src/renderer/src/components/task-page/SourceBar.tsx +++ b/src/renderer/src/components/task-page/SourceBar.tsx @@ -6,6 +6,7 @@ import { X, ExternalLink } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearScopeSelector } from '@/components/linear-scope-selector' +import { getGitHubProjectSourceSummary } from '../task-page-source-context' import { Select, SelectTrigger, @@ -33,6 +34,10 @@ export function TaskPageSourceBar({ taskSource, taskSourceAvailabilityNoticeByProvider, taskSourceContextSummary, + githubMode, + accountBackedTaskSourceHostId, + hostRegistryById, + hostLabelById, taskSourceManuallyChangedRef, setSelectedJiraIssueKey, setSelectedJiraIssueFallback, @@ -48,6 +53,16 @@ export function TaskPageSourceBar({ handleLinearTeamSelectionChange, handleLinearScopeOpen } = model + const sourceSummary = + taskSource === 'github' && githubMode === 'project' + ? getGitHubProjectSourceSummary({ + providerLabel: + visibleSourceOptions.find((source) => source.id === 'github')?.label ?? 'GitHub', + hostId: accountBackedTaskSourceHostId, + host: hostRegistryById.get(accountBackedTaskSourceHostId), + hostLabelById + }) + : taskSourceContextSummary return (
- {taskSourceContextSummary.label} + {sourceSummary.label}
{taskSource === 'linear' && linearConnected ? ( diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts index af5e2879d0c..c99f13fe0ef 100644 --- a/src/renderer/src/components/task-source-context-summary.ts +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -150,7 +150,7 @@ function getRepoBackedTaskSourceSummary(args: { } } -function getAccountBackedTaskSourceSummary( +export function getAccountBackedTaskSourceSummary( providerLabel: string, args: { accountLabel: string | null | undefined diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts index ca1e5b24da0..f6943566b4e 100644 --- a/src/renderer/src/store/github/project-actions.ts +++ b/src/renderer/src/store/github/project-actions.ts @@ -25,9 +25,9 @@ export const createProjectActions = ( GitHubSlice, 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' > => ({ - fetchProjectViewTable: async (args, options) => { - const target = getActiveRuntimeTarget(get().settings) - const sourceScope = projectViewSourceScope(get().settings) + fetchProjectViewTable: async (sourceSettings, args, options) => { + const target = getActiveRuntimeTarget(sourceSettings) + const sourceScope = projectViewSourceScope(sourceSettings) const requestKey = projectViewRequestKey(args, sourceScope) // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. diff --git a/src/renderer/src/store/github/provider-request-coalescing.test.ts b/src/renderer/src/store/github/provider-request-coalescing.test.ts index f012999ce65..5367fb1989d 100644 --- a/src/renderer/src/store/github/provider-request-coalescing.test.ts +++ b/src/renderer/src/store/github/provider-request-coalescing.test.ts @@ -308,8 +308,10 @@ describe('GitHub provider request upgrade coalescing', () => { .mockReturnValueOnce(replacementWeak.promise) .mockReturnValue(forcedResult.promise) - const first = store.getState().fetchProjectViewTable(projectViewRequest) - const forcedFetch = store.getState().fetchProjectViewTable(projectViewRequest, { force: true }) + const first = store.getState().fetchProjectViewTable(null, projectViewRequest) + const forcedFetch = store + .getState() + .fetchProjectViewTable(null, projectViewRequest, { force: true }) // Why: only a non-forced entry can be superseded mid-flight, and it has to appear in the // microtask after the first request clears the key — before the forced waiter re-checks it. const pending = inflightProjectViewRequests.get( @@ -318,7 +320,7 @@ describe('GitHub provider request upgrade coalescing', () => { expect(pending).toBeDefined() let replacementFetch: Promise | undefined void pending?.promise.then(() => { - replacementFetch = store.getState().fetchProjectViewTable(projectViewRequest) + replacementFetch = store.getState().fetchProjectViewTable(null, projectViewRequest) }) const staleError = { type: 'network_error' as const, message: 'first attempt offline' } diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 6a5116804d3..6943d07ab5d 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -26,6 +26,7 @@ import type { } from '../../../../shared/github/project-result-types' import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { CacheEntry, FetchOptions, @@ -253,7 +254,9 @@ export type GitHubSlice = { evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void // ── ProjectV2 view cache ───────────────────────────────────────────── projectViewCache: Record> + /** `sourceSettings` names the board's row-less source host; the focused server is never read. */ fetchProjectViewTable: ( + sourceSettings: Pick | null, args: GetProjectViewTableArgs, options?: FetchOptions ) => Promise diff --git a/src/renderer/src/store/slices/github-project-request-coordination.test.ts b/src/renderer/src/store/slices/github-project-request-coordination.test.ts index 40a64f77ce7..8b77d5a02a2 100644 --- a/src/renderer/src/store/slices/github-project-request-coordination.test.ts +++ b/src/renderer/src/store/slices/github-project-request-coordination.test.ts @@ -50,7 +50,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const table = makeTable() store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) - await expect(store.getState().fetchProjectViewTable(request)).resolves.toEqual({ + await expect(store.getState().fetchProjectViewTable(null, request)).resolves.toEqual({ ok: true, data: table }) @@ -62,8 +62,8 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const pending = Promise.withResolvers() mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) - const first = store.getState().fetchProjectViewTable(request) - const duplicate = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) + const duplicate = store.getState().fetchProjectViewTable(null, request) pending.resolve({ ok: true, data: makeTable() }) await expect(Promise.all([first, duplicate])).resolves.toEqual([ @@ -80,9 +80,9 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { .mockReturnValueOnce(weak.promise) .mockResolvedValue({ ok: true, data: makeTable('forced') }) - const first = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) const forced = Array.from({ length: 20 }, () => - store.getState().fetchProjectViewTable(request, { force: true }) + store.getState().fetchProjectViewTable(null, request, { force: true }) ) weak.resolve({ ok: true, data: makeTable('weak') }) @@ -100,17 +100,17 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { store.setState({ projectViewCache: { [cacheKey]: { data: stale, fetchedAt: 1 } } }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) - await expect(store.getState().fetchProjectViewTable(request, { force: true })).resolves.toEqual( - { - ok: false, - error - } - ) + await expect( + store.getState().fetchProjectViewTable(null, request, { force: true }) + ).resolves.toEqual({ + ok: false, + error + }) expect(store.getState().projectViewCache[cacheKey]).toMatchObject({ data: stale, error }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 @@ -123,7 +123,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 diff --git a/src/renderer/src/store/slices/github-project-view-tables.test.ts b/src/renderer/src/store/slices/github-project-view-tables.test.ts index faa589824c3..43e51bf0dce 100644 --- a/src/renderer/src/store/slices/github-project-view-tables.test.ts +++ b/src/renderer/src/store/slices/github-project-view-tables.test.ts @@ -23,7 +23,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) - it('routes project table fetches through the active runtime environment', async () => { + it('routes project table fetches through the board source, not the focused server', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } @@ -60,17 +60,20 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - const result = await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + const result = await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-board' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) expect(result.ok).toBe(true) expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', + selector: 'env-board', method: 'github.project.viewTable', params: { owner: 'acme', @@ -119,16 +122,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-1' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) - store.setState({ - settings: { activeRuntimeEnvironmentId: null } - } as Partial) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: true, data: { @@ -156,7 +159,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { } }) - const localResult = await store.getState().fetchProjectViewTable({ + const localResult = await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -207,7 +210,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { .mockResolvedValueOnce({ ok: true, data: makeTable('ghe.example', 'enterprise-project') }) for (const host of ['github.com', 'ghe.example']) { - await store.getState().fetchProjectViewTable({ + await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -483,7 +486,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { try { const store = createTestStore() mockApi.gh.getProjectViewTable.mockImplementation( - async (args: Parameters[0]) => ({ + async (args: Parameters[1]) => ({ ok: true, data: { project: { @@ -514,6 +517,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { for (let i = 0; i <= 500; i++) { vi.setSystemTime(1_000 + i) await store.getState().fetchProjectViewTable( + null, { owner: 'acme', ownerType: 'organization', diff --git a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts index 30717fca0d9..64a51027d59 100644 --- a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts +++ b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts @@ -30,7 +30,7 @@ describe('GitHub provider request concurrency', () => { .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') .catch(() => []) ) - const queuedProject = store.getState().fetchProjectViewTable({ + const queuedProject = store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, diff --git a/src/shared/host-operation-ownership.test.ts b/src/shared/host-operation-ownership.test.ts index 8253b653570..6e485063e83 100644 --- a/src/shared/host-operation-ownership.test.ts +++ b/src/shared/host-operation-ownership.test.ts @@ -20,6 +20,15 @@ describe('getHostOperationOwnership', () => { expect(getHostOperationOwnership('jira.getIssueStream')).toBe('scope-picked') }) + it('reads row-less GitHub Projects board methods as scope-picked', () => { + expect(getHostOperationOwnership('github.project.viewTable')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listAccessible')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listViews')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.resolveRef')).toBe('scope-picked') + // Row edits route to the matched repo's owner first, so they stay credential-sourced. + expect(getHostOperationOwnership('github.project.updateItemField')).toBe('credential') + }) + it('keeps client effects client and returns null for unknown names', () => { expect(getHostOperationOwnership('shell.openPath')).toBe('client') expect(getHostOperationOwnership('nope.call')).toBeNull() diff --git a/src/shared/host-operation-ownership.ts b/src/shared/host-operation-ownership.ts index a3d71cda616..f13b7ec1298 100644 --- a/src/shared/host-operation-ownership.ts +++ b/src/shared/host-operation-ownership.ts @@ -67,6 +67,17 @@ export const HOST_OPERATION_OWNERSHIP = { worktree: 'host' } as const satisfies Record +/** Methods whose owner differs from their namespace: row-less forge reads use the source picker. */ +const HOST_OPERATION_METHOD_OWNERSHIP = { + 'github.project.listAccessible': 'scope-picked', + 'github.project.listViews': 'scope-picked', + 'github.project.resolveRef': 'scope-picked', + 'github.project.viewTable': 'scope-picked' +} as const satisfies Record + +const OWNERSHIP_BY_METHOD: ReadonlyMap = new Map( + Object.entries(HOST_OPERATION_METHOD_OWNERSHIP) +) const OWNERSHIP_BY_NAMESPACE: ReadonlyMap = new Map( Object.entries(HOST_OPERATION_OWNERSHIP) ) @@ -74,7 +85,9 @@ const STREAM_METHOD = /^(un)?(subscribe|watch)/i /** Null for a namespace nobody classified, so callers fail closed. */ export function getHostOperationOwnership(method: string): HostOperationOwnership | null { - const ownership = OWNERSHIP_BY_NAMESPACE.get(method.slice(0, method.indexOf('.'))) + const ownership = + OWNERSHIP_BY_METHOD.get(method) ?? + OWNERSHIP_BY_NAMESPACE.get(method.slice(0, method.indexOf('.'))) if (!ownership || !method.includes('.')) { return null }