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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 09:17:11 -07:00
committed by GitHub
co-authored by m4air
parent 5b676777ba
commit 5c7c493042
20 changed files with 219 additions and 95 deletions
-1
View File
@@ -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
@@ -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<GitHubProjectViewSummary[]>([])
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(
() =>
@@ -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 ? (
<ProjectSearchInput
@@ -1,7 +1,8 @@
import React, { useCallback } from 'react'
import React, { useCallback, useMemo } from 'react'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { useAppStore } from '@/store'
import { settingsForProjectViewCacheKey } from '@/store/github/cache-identity'
import { translate } from '@/i18n/i18n'
import ProjectViewList from './ProjectViewList'
import ProjectBoard from './ProjectBoard'
@@ -20,17 +21,37 @@ import type {
GitHubProjectFieldMutationValue,
GitHubProjectRow
} from '../../../../shared/github/project-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { getTaskSourceRuntimeSettings } from '../../../../shared/task-source-context'
type Props = { selectedRepoIds: ReadonlySet<string> }
type Props = {
selectedRepoIds: ReadonlySet<string>
/** 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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
@@ -44,8 +65,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
) : null}
<ProjectViewBody tableState={tableState} rowActions={rowActions} />
<ProjectItemSlugDialog
projectOrigin={rowActions.missingDialogs.slugDialog?.origin ?? null}
sourceSettings={tableState.settings}
projectOrigin={slugOrigin}
sourceSettings={slugSourceSettings ?? tableState.sourceSettings}
onClose={() => rowActions.setSlugDialog(null)}
/>
<ProjectMissingRepoDialog
@@ -150,7 +171,7 @@ function ProjectViewBody({
}
}}
onStartWork={rowActions.startWork}
sourceSettings={tableState.settings}
sourceSettings={tableState.sourceSettings}
/>
)
if (visibleTable.selectedView.layout === 'ROADMAP_LAYOUT') {
@@ -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<typeof getActiveRuntimeTarget>[0]
/** The board's row-less source host, from `getTaskSourceRuntimeSettings`; never the focused server. */
export type ProjectSourceSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
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<ListAccessibleProjectsResult> {
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<ResolveProjectRefResult> {
@@ -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<typeof getProjectPickerRuntimeScope>[0],
settings: ProjectSourceSettings,
browseHost: string
): {
browseProjects: GitHubProjectSummary[]
@@ -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<typeof getActiveRuntimeTarget>[0]
async function listProjectViewsForRuntime(
settings: Settings,
args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number; host?: string }
): Promise<ListProjectViewsResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', args, {
timeoutMs: 30_000
})
: window.api.gh.listProjectViews(args)
}
export function useProjectViewTable(selectedRepoIds: ReadonlySet<string>) {
export function useProjectViewTable(
selectedRepoIds: ReadonlySet<string>,
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<string>) {
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<string>) {
setError(null)
try {
const result: GetProjectViewTableResult = await fetchProjectViewTable(
sourceSettings,
{
owner: selection.owner,
ownerType: selection.ownerType,
@@ -93,7 +81,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet<string>) {
}
}
},
[fetchProjectViewTable, mountedRef]
[fetchProjectViewTable, mountedRef, sourceSettings]
)
const projectIdentity = activeProject ? githubProjectIdentityKey(activeProject) : null
@@ -143,7 +131,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet<string>) {
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<string>) {
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<string>) {
)
return {
settings,
sourceSettings,
activeProject,
sourceScope,
viewId,
@@ -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<string, string>
}): 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
@@ -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<ExecutionHostRegistryEntry>) =>
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')
})
})
@@ -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' ? (
<div className="mt-3 flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md border border-border/50 bg-muted/50 shadow-sm">
<ProjectViewWrapper selectedRepoIds={repoSelection} />
<ProjectViewWrapper
selectedRepoIds={repoSelection}
sourceHostId={accountBackedTaskSourceHostId}
/>
</div>
) : taskSource === 'github' ? (
// Why: bottom of the joined GitHub list card — flush under the filter
@@ -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 (
<div className="flex items-center justify-between gap-2">
<div
@@ -128,9 +143,9 @@ export function TaskPageSourceBar({
})}
<div
className="hidden min-w-0 max-w-[min(420px,40vw)] items-center rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-xs text-muted-foreground sm:flex"
title={taskSourceContextSummary.title}
title={sourceSummary.title}
>
<span className="truncate">{taskSourceContextSummary.label}</span>
<span className="truncate">{sourceSummary.label}</span>
</div>
</div>
{taskSource === 'linear' && linearConnected ? (
@@ -150,7 +150,7 @@ function getRepoBackedTaskSourceSummary(args: {
}
}
function getAccountBackedTaskSourceSummary(
export function getAccountBackedTaskSourceSummary(
providerLabel: string,
args: {
accountLabel: string | null | undefined
@@ -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.
@@ -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<GetProjectViewTableResult> | 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' }
@@ -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<string, ProjectViewCacheEntry<GitHubProjectTable>>
/** `sourceSettings` names the board's row-less source host; the focused server is never read. */
fetchProjectViewTable: (
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
args: GetProjectViewTableArgs,
options?: FetchOptions
) => Promise<GetProjectViewTableResult>
@@ -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<GetProjectViewTableResult>()
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
@@ -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<AppState>)
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<AppState['fetchProjectViewTable']>[0]) => ({
async (args: Parameters<AppState['fetchProjectViewTable']>[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',
@@ -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,
@@ -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()
+14 -1
View File
@@ -67,6 +67,17 @@ export const HOST_OPERATION_OWNERSHIP = {
worktree: 'host'
} as const satisfies Record<string, HostOperationOwnership>
/** 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<string, HostOperationOwnership>
const OWNERSHIP_BY_METHOD: ReadonlyMap<string, HostOperationOwnership> = new Map(
Object.entries(HOST_OPERATION_METHOD_OWNERSHIP)
)
const OWNERSHIP_BY_NAMESPACE: ReadonlyMap<string, HostOperationOwnership> = 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
}