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 }