Scope GitHub Project rows to selected repos (#6174)

* Scope GitHub Project rows to selected repos

Adds the selected-repo Project scoping implementation and documents the UI behavior in docs/github-project-selected-repo-popup.md.

* rm design doc

* fix: address review findings
This commit is contained in:
Jinjing 2026-06-23 10:22:54 -07:00 committed by GitHub
parent 3e955c0377
commit aec7bb6aa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 603 additions and 139 deletions

View File

@ -7762,91 +7762,84 @@ export default function TaskPage(): React.JSX.Element {
})}
</div>
) : null}
{/* Why: the repo combobox filters Items mode by repo. In
Project mode the row set comes from the project's
view filter (server-side), so this control would be
inert hide it to avoid suggesting it does
something. */}
{githubMode !== 'project' && (
<>
<div className="min-w-0 max-w-[220px] shrink-0">
<TaskProjectSourceCombobox
groups={taskPickerGroups}
selected={repoSelection}
getRepoHostLabel={getTaskPickerRepoHostLabel}
onChange={(next) => {
const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
setRepoSelection(normalized)
void updateSettings({ defaultRepoSelection: [...normalized] }).catch(
() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
}
)
}}
onSelectAll={() => {
const allIds = new Set(taskPickerRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
{/* Why: Project rows are now repo-scoped too, so the
selection must stay visible in both GitHub modes. */}
<div className="min-w-0 max-w-[220px] shrink-0">
<TaskProjectSourceCombobox
groups={taskPickerGroups}
selected={repoSelection}
getRepoHostLabel={getTaskPickerRepoHostLabel}
onChange={(next) => {
const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
setRepoSelection(normalized)
void updateSettings({ defaultRepoSelection: [...normalized] }).catch(
() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
})
}}
triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => {
if (!selectedGitHubRepoExternalLink?.url) {
return
}
void window.api.shell.openUrl(selectedGitHubRepoExternalLink.url)
}}
aria-label={
selectedGitHubRepoExternalLink
? translate(
'auto.components.TaskPage.8d1e17a3ef',
'Open {{value0}} in GitHub',
{ value0: selectedGitHubRepoExternalLink.label }
)
: translate(
'auto.components.TaskPage.d1132848f8',
'Select one GitHub project to open in GitHub'
)
}
className="h-8 w-8 rounded-md border-border/50 bg-muted/50 text-foreground shadow-sm transition hover:bg-muted/50"
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{selectedGitHubRepoExternalLink
)
}
)
}}
onSelectAll={() => {
const allIds = new Set(taskPickerRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
})
}}
triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => {
if (!selectedGitHubRepoExternalLink?.url) {
return
}
void window.api.shell.openUrl(selectedGitHubRepoExternalLink.url)
}}
aria-label={
selectedGitHubRepoExternalLink
? translate(
'auto.components.TaskPage.8d1e17a3ef',
'Open {{value0}} in GitHub',
{ value0: selectedGitHubRepoExternalLink.label }
)
: translate(
'auto.components.TaskPage.bc46d8204e',
'Select one project to open in GitHub'
)}
</TooltipContent>
</Tooltip>
</>
)}
'auto.components.TaskPage.d1132848f8',
'Select one GitHub project to open in GitHub'
)
}
className="h-8 w-8 rounded-md border-border/50 bg-muted/50 text-foreground shadow-sm transition hover:bg-muted/50"
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{selectedGitHubRepoExternalLink
? translate(
'auto.components.TaskPage.8d1e17a3ef',
'Open {{value0}} in GitHub',
{ value0: selectedGitHubRepoExternalLink.label }
)
: translate(
'auto.components.TaskPage.bc46d8204e',
'Select one project to open in GitHub'
)}
</TooltipContent>
</Tooltip>
</div>
) : null}
@ -8635,7 +8628,7 @@ export default function TaskPage(): React.JSX.Element {
)
) : 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 />
<ProjectViewWrapper selectedRepoIds={repoSelection} />
</div>
) : taskSource === 'github' ? (
<div className="flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-muted/50 shadow-sm">

View File

@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types'
import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker'
import ProjectViewList from './ProjectViewList'
import ProjectItemSlugDialog from './ProjectItemSlugDialog'
import { filterProjectTableRowsByOpenRepos } from './project-row-filtering'
import {
filterProjectTableRowsBySelectedRepos,
resolveSelectedProjectRowRepo
} from './project-row-filtering'
import {
resolveMissingRepoProjectDialogState,
resolveRepoBackedProjectDialogState
} from './project-dialog-state'
import {
getSelectedRepoFingerprint,
getNextVisibleProjectTableCache,
getVisibleProjectTable,
type CachedVisibleProjectTable
} from './project-visible-table-cache'
import { translate } from '@/i18n/i18n'
type Props = Record<string, never>
type Props = {
selectedRepoIds: ReadonlySet<string>
}
const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new'
@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters<typeof getActiveRuntimeT
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
}
export default function ProjectViewWrapper(_props: Props = {} as Props): React.JSX.Element {
export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const projectViewCache = useAppStore((s) => s.projectViewCache)
const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable)
@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const table: GitHubProjectTable | null = currentCacheKey
? (projectViewCache[currentCacheKey]?.data ?? null)
: null
const selectedRepoFingerprint = useMemo(
() => getSelectedRepoFingerprint(selectedRepoIds),
[selectedRepoIds]
)
const filteredTable = useMemo(
() => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null),
[table, slugIndexReady, lookupSlug]
() =>
table && slugIndexReady
? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds)
: null,
[table, slugIndexReady, lookupSlug, selectedRepoIds]
)
const lastFilteredTableRef = useRef<CachedVisibleProjectTable | null>(null)
// Why: this cache only prevents a blank table while the repo slug index
@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
// a second render after every filtered-table change.
lastFilteredTableRef.current = getNextVisibleProjectTableCache({
currentCacheKey,
selectedRepoFingerprint,
sourceTable: table,
slugIndexReady,
filteredTable,
@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
})
const visibleTable = getVisibleProjectTable({
currentCacheKey,
selectedRepoFingerprint,
slugIndexReady,
filteredTable,
cachedTable: lastFilteredTableRef.current
@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
} | null>(null)
const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos])
const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds)
const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(
dialogRepoItem,
liveRepoIds,
selectedRepoIds
)
if (resolvedDialogRepoItem !== dialogRepoItem) {
// Why: repo-backed Project dialogs cannot edit after their repo leaves
// Orca; clear them before the modal tree receives stale repo ids.
@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
slugIndexReady,
slugDialog,
repoNotInOrca,
lookupSlug
lookupSlug,
selectedRepoIds
})
if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) {
// Why: once a previously missing repo is registered, Project rows should
@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
[]
)
const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => {
if (row.content.url) {
void window.api.shell.openUrl(row.content.url)
}
toast.message(message)
}, [])
const handleOpenDialog = useCallback(
(row: GitHubProjectRow) => {
if (!currentCacheKey || !table) {
@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
}
return
}
const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
const matched = matches.length === 1 ? matches[0] : null
if (matched) {
const workItem = buildWorkItem(row, matched.id)
const resolution = resolveSelectedProjectRowRepo({
row,
lookupSlug,
slugIndexReady,
selectedRepoIds
})
if (resolution.status === 'loading') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.f352abf7c3',
'Repository list is updating.'
)
)
return
}
if (resolution.status === 'selected_match') {
const workItem = buildWorkItem(row, resolution.repo.id)
if (workItem) {
setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin })
setDialogRepoItem({
workItem,
repoPath: resolution.repo.path,
repoId: resolution.repo.id,
origin
})
return
}
}
// Unknown repo — use the simplified slug-mode dialog.
setSlugDialog({ origin })
if (resolution.status === 'no_global_match') {
// Unknown repo — use the simplified slug-mode dialog.
setSlugDialog({ origin })
return
}
if (resolution.status === 'unselected_match') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.1ce21b8cff',
'This item is outside the selected repositories.'
)
)
return
}
if (resolution.status === 'ambiguous_selected_match') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.030de75bc5',
'This item matches multiple selected repositories.'
)
)
}
},
[currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem]
[
currentCacheKey,
table,
buildOrigin,
lookupSlug,
slugIndexReady,
selectedRepoIds,
openProjectRowUrlWithToast,
buildWorkItem
]
)
const handleStartWork = useCallback(
@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!origin) {
return
}
const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
const matched = matches.length === 1 ? matches[0] : null
if (!matched) {
const resolution = resolveSelectedProjectRowRepo({
row,
lookupSlug,
slugIndexReady,
selectedRepoIds
})
if (resolution.status === 'loading') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.f352abf7c3',
'Repository list is updating.'
)
)
return
}
if (resolution.status === 'no_global_match') {
setRepoNotInOrca({
owner: origin.owner,
repo: origin.repo,
@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
})
return
}
const workItem = buildWorkItem(row, matched.id)
if (resolution.status === 'unselected_match') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.1ce21b8cff',
'This item is outside the selected repositories.'
)
)
return
}
if (resolution.status === 'ambiguous_selected_match') {
openProjectRowUrlWithToast(
row,
translate(
'auto.components.github.project.ProjectViewWrapper.030de75bc5',
'This item matches multiple selected repositories.'
)
)
return
}
if (resolution.status !== 'selected_match') {
return
}
const workItem = buildWorkItem(row, resolution.repo.id)
if (!workItem) {
return
}
void launchWorkItemDirect({
item: workItem,
repoId: matched.id,
repoId: resolution.repo.id,
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: () => {
@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
}
})
},
[currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem]
[
currentCacheKey,
table,
buildOrigin,
lookupSlug,
slugIndexReady,
selectedRepoIds,
openProjectRowUrlWithToast,
buildWorkItem
]
)
const handleEditAssignees = useCallback(

View File

@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => {
it('keeps a repo-backed dialog when the repo still exists', () => {
const dialog = { repoId: 'repo-1', label: 'Issue 1' }
expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog)
expect(
resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1']))
).toBe(dialog)
})
it('clears a repo-backed dialog when its repo is removed', () => {
expect(
resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2']))
resolveRepoBackedProjectDialogState(
{ repoId: 'repo-1' },
new Set(['repo-2']),
new Set(['repo-1'])
)
).toBeNull()
})
it('clears a repo-backed dialog when its repo is no longer selected', () => {
expect(
resolveRepoBackedProjectDialogState(
{ repoId: 'repo-1' },
new Set(['repo-1']),
new Set(['repo-2'])
)
).toBeNull()
})
})
describe('resolveMissingRepoProjectDialogState', () => {
it('waits for the slug index before closing missing-repo dialogs', () => {
it('clears fallback dialogs while the slug index is rebuilding', () => {
const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: false,
slugDialog,
repoNotInOrca,
lookupSlug: () => ['repo-1']
lookupSlug: () => [{ id: 'repo-1' }],
selectedRepoIds: new Set(['repo-1'])
})
).toEqual({ slugDialog, repoNotInOrca })
).toEqual({ slugDialog: null, repoNotInOrca: null })
})
it('clears slug fallback dialogs once the repo slug resolves', () => {
@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: true,
slugDialog,
repoNotInOrca,
lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : [])
lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []),
selectedRepoIds: new Set(['repo-1'])
})
expect(result.slugDialog).toBeNull()
@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: true,
slugDialog,
repoNotInOrca,
lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : [])
lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []),
selectedRepoIds: new Set(['repo-1'])
})
expect(result.slugDialog).toBe(slugDialog)
expect(result.repoNotInOrca).toBeNull()
})
it('clears fallback dialogs when the repo is globally known but not selected', () => {
const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
const result = resolveMissingRepoProjectDialogState({
slugIndexReady: true,
slugDialog,
repoNotInOrca,
lookupSlug: () => [{ id: 'repo-2' }],
selectedRepoIds: new Set(['repo-1'])
})
expect(result).toEqual({ slugDialog: null, repoNotInOrca: null })
})
it('keeps missing-repo fallback dialogs when there are no global matches', () => {
const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
const result = resolveMissingRepoProjectDialogState({
slugIndexReady: true,
slugDialog,
repoNotInOrca,
lookupSlug: () => [],
selectedRepoIds: new Set(['repo-1'])
})
expect(result).toEqual({ slugDialog, repoNotInOrca })
})
})

View File

@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = {
repo: string
}
type LookupSlug = (slug: string) => readonly unknown[]
type RepoMatch = {
id: string
}
function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean {
return lookupSlug(`${owner}/${repo}`).length > 0
type LookupSlug = (slug: string) => readonly RepoMatch[]
function shouldCloseFallbackDialog(args: {
lookupSlug: LookupSlug
selectedRepoIds: ReadonlySet<string>
owner: string
repo: string
}): boolean {
const matches = args.lookupSlug(`${args.owner}/${args.repo}`)
const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length
const unselectedMatchCount = matches.length - selectedMatchCount
return selectedMatchCount > 0 || unselectedMatchCount > 0
}
export function resolveRepoBackedProjectDialogState<T extends RepoBackedProjectDialogState>(
dialog: T | null,
liveRepoIds: ReadonlySet<string>
liveRepoIds: ReadonlySet<string>,
selectedRepoIds: ReadonlySet<string>
): T | null {
if (dialog && !liveRepoIds.has(dialog.repoId)) {
if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) {
return null
}
return dialog
@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState<
slugDialog: TSlugDialog | null
repoNotInOrca: TRepoNotInOrca | null
lookupSlug: LookupSlug
selectedRepoIds: ReadonlySet<string>
}): {
slugDialog: TSlugDialog | null
repoNotInOrca: TRepoNotInOrca | null
} {
const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args
const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args
if (!slugIndexReady) {
return { slugDialog, repoNotInOrca }
return { slugDialog: null, repoNotInOrca: null }
}
return {
slugDialog:
slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo)
slugDialog &&
shouldCloseFallbackDialog({
lookupSlug,
selectedRepoIds,
owner: slugDialog.origin.owner,
repo: slugDialog.origin.repo
})
? null
: slugDialog,
repoNotInOrca:
repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo)
repoNotInOrca &&
shouldCloseFallbackDialog({
lookupSlug,
selectedRepoIds,
owner: repoNotInOrca.owner,
repo: repoNotInOrca.repo
})
? null
: repoNotInOrca
}

View File

@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types'
import type { Repo } from '../../../../shared/types'
import { filterProjectTableRowsByOpenRepos } from './project-row-filtering'
import {
filterProjectTableRowsByOpenRepos,
filterProjectTableRowsBySelectedRepos,
resolveSelectedProjectRowRepo
} from './project-row-filtering'
function repo(id: string): Repo {
return {
@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => {
expect(filtered.totalCount).toBe(0)
})
})
describe('filterProjectTableRowsBySelectedRepos', () => {
it('keeps a row when at least one slug match is selected', () => {
const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
(slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]),
true,
new Set(['repo-1'])
)
expect(filtered.rows.map((r) => r.id)).toEqual(['visible'])
expect(filtered.totalCount).toBe(1)
})
it('filters a row when only unselected repos match', () => {
const rows = [row('hidden', 'acme/orca')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
() => [repo('repo-2')],
true,
new Set(['repo-1'])
)
expect(filtered.rows).toEqual([])
expect(filtered.totalCount).toBe(0)
})
it('keeps a row with multiple selected matches for action ambiguity handling', () => {
const rows = [row('ambiguous', 'acme/orca')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
() => [repo('repo-1'), repo('repo-2'), repo('repo-3')],
true,
new Set(['repo-1', 'repo-2'])
)
expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous'])
})
})
describe('resolveSelectedProjectRowRepo', () => {
it('reports loading without reading stale slug matches', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('loading', 'acme/orca'),
lookupSlug: () => {
throw new Error('should not read stale matches')
},
slugIndexReady: false,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('loading')
})
it('reports invalid slug for rows without a repository', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('missing-slug', null),
lookupSlug: () => [repo('repo-1')],
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('invalid_slug')
})
it('reports no global match when Orca has no repo for the slug', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('missing', 'acme/orca'),
lookupSlug: () => [],
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('no_global_match')
})
it('reports global-only matches when the repo is not selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('unselected', 'acme/orca'),
lookupSlug: () => [repo('repo-2')],
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('unselected_match')
})
it('returns the selected match when exactly one matching repo is selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('selected', 'acme/orca'),
lookupSlug: () => [repo('repo-1'), repo('repo-2')],
slugIndexReady: true,
selectedRepoIds: new Set(['repo-2'])
})
expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } })
})
it('reports ambiguity when multiple matching repos are selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('ambiguous', 'acme/orca'),
lookupSlug: () => [repo('repo-1'), repo('repo-2')],
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1', 'repo-2'])
})
expect(resolution.status).toBe('ambiguous_selected_match')
})
})

View File

@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types'
export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[]
export type SelectedProjectRowResolution =
| { status: 'loading' }
| { status: 'invalid_slug' }
| { status: 'no_global_match' }
| { status: 'unselected_match'; globalMatches: readonly Repo[] }
| { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] }
| {
status: 'ambiguous_selected_match'
selectedMatches: readonly Repo[]
globalMatches: readonly Repo[]
}
export function resolveSelectedProjectRowRepo(input: {
row: GitHubProjectRow
lookupSlug: ProjectRowSlugLookup
slugIndexReady: boolean
selectedRepoIds: ReadonlySet<string>
}): SelectedProjectRowResolution {
if (!input.slugIndexReady) {
return { status: 'loading' }
}
const repository = input.row.content.repository
if (!repository) {
return { status: 'invalid_slug' }
}
const [owner, repo] = repository.split('/')
if (!owner || !repo) {
return { status: 'invalid_slug' }
}
const globalMatches = input.lookupSlug(repository)
if (globalMatches.length === 0) {
return { status: 'no_global_match' }
}
const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id))
if (selectedMatches.length === 0) {
return { status: 'unselected_match', globalMatches }
}
if (selectedMatches.length === 1) {
return { status: 'selected_match', repo: selectedMatches[0], globalMatches }
}
return { status: 'ambiguous_selected_match', selectedMatches, globalMatches }
}
export function projectRowHasOpenRepo(
row: GitHubProjectRow,
lookupSlug: ProjectRowSlugLookup
@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos(
}
return { ...table, rows, totalCount: rows.length }
}
export function filterProjectTableRowsBySelectedRepos(
table: GitHubProjectTable,
lookupSlug: ProjectRowSlugLookup,
slugIndexReady: boolean,
selectedRepoIds: ReadonlySet<string>
): GitHubProjectTable {
const rows = table.rows.filter((row) => {
const resolution = resolveSelectedProjectRowRepo({
row,
lookupSlug,
slugIndexReady,
selectedRepoIds
})
return (
resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match'
)
})
if (rows.length === table.rows.length && table.totalCount === rows.length) {
return table
}
return { ...table, rows, totalCount: rows.length }
}

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { GitHubProjectTable } from '../../../../shared/github-project-types'
import {
getSelectedRepoFingerprint,
getNextVisibleProjectTableCache,
getVisibleProjectTable
getVisibleProjectTable,
getVisibleProjectTableCacheKey
} from './project-visible-table-cache'
function table(id: string): GitHubProjectTable {
@ -17,20 +19,22 @@ describe('project visible table cache', () => {
expect(
getNextVisibleProjectTableCache({
currentCacheKey: 'project:view',
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable,
slugIndexReady: true,
filteredTable,
previous: null
})
).toEqual({ cacheKey: 'project:view', table: filteredTable })
).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable })
})
it('keeps the previous cache while the slug index is rebuilding', () => {
const previous = { cacheKey: 'project:view', table: table('previous') }
const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') }
expect(
getNextVisibleProjectTableCache({
currentCacheKey: 'project:view',
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable: table('source'),
slugIndexReady: false,
filteredTable: null,
@ -45,6 +49,7 @@ describe('project visible table cache', () => {
expect(
getNextVisibleProjectTableCache({
currentCacheKey: null,
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable: null,
slugIndexReady: false,
filteredTable: null,
@ -54,11 +59,12 @@ describe('project visible table cache', () => {
})
it('shows a matching cached table while the slug index is rebuilding', () => {
const cachedTable = { cacheKey: 'project:view', table: table('cached') }
const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') }
expect(
getVisibleProjectTable({
currentCacheKey: 'project:view',
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
slugIndexReady: false,
filteredTable: null,
cachedTable
@ -67,15 +73,36 @@ describe('project visible table cache', () => {
})
it('does not show stale cached data for a different cache key', () => {
const cachedTable = { cacheKey: 'other:view', table: table('cached') }
const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') }
expect(
getVisibleProjectTable({
currentCacheKey: 'project:view',
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
slugIndexReady: false,
filteredTable: null,
cachedTable
})
).toBeNull()
})
it('does not show stale cached data for a different repo selection', () => {
const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') }
expect(
getVisibleProjectTable({
currentCacheKey: 'project:view',
selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])),
slugIndexReady: false,
filteredTable: null,
cachedTable
})
).toBeNull()
})
it('layers selection only onto the renderer visible-table cache key', () => {
const storeCacheKey = 'organization:stablyai:1:view-id:local'
expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey)
})
})

View File

@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = {
table: GitHubProjectTable
}
export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet<string>): string {
return JSON.stringify([...selectedRepoIds].sort())
}
export function getVisibleProjectTableCacheKey(
currentCacheKey: string | null,
selectedRepoFingerprint: string
): string | null {
return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null
}
export function getNextVisibleProjectTableCache(input: {
currentCacheKey: string | null
selectedRepoFingerprint: string
sourceTable: GitHubProjectTable | null
slugIndexReady: boolean
filteredTable: GitHubProjectTable | null
previous: CachedVisibleProjectTable | null
}): CachedVisibleProjectTable | null {
if (!input.currentCacheKey || !input.sourceTable) {
const visibleCacheKey = getVisibleProjectTableCacheKey(
input.currentCacheKey,
input.selectedRepoFingerprint
)
if (!visibleCacheKey || !input.sourceTable) {
return null
}
if (input.slugIndexReady && input.filteredTable) {
return { cacheKey: input.currentCacheKey, table: input.filteredTable }
return { cacheKey: visibleCacheKey, table: input.filteredTable }
}
return input.previous
}
export function getVisibleProjectTable(input: {
currentCacheKey: string | null
selectedRepoFingerprint: string
slugIndexReady: boolean
filteredTable: GitHubProjectTable | null
cachedTable: CachedVisibleProjectTable | null
@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: {
if (input.slugIndexReady || !input.currentCacheKey) {
return input.filteredTable
}
return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null
const visibleCacheKey = getVisibleProjectTableCacheKey(
input.currentCacheKey,
input.selectedRepoFingerprint
)
return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null
}

View File

@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => {
)
const modalSection = sourceBetween(
TASK_PAGE_SOURCE,
'<ProjectViewWrapper />',
'<ProjectViewWrapper selectedRepoIds={repoSelection} />',
'<GitLabItemDialog'
)
expect(modalSection).toContain('selectedRepoIds={repoSelection}')
expect(detailSection).toContain('workItem={dialogWorkItem}')
expect(detailSection).toContain('<GitHubItemDialog')
expect(detailSection).toContain('sourceContext={dialogSourceContext}')

View File

@ -1846,7 +1846,10 @@
"22df63c393": "Sub-issue data is unavailable for your token.",
"067119985c": "GitHub search, e.g. assignee:@me is:open",
"1850fceac8": "{{value0}}/{{value1}} isn't added to Orca. Add it to start work, or open in GitHub.",
"1aa7c952b9": "Project view"
"1aa7c952b9": "Project view",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
},
"slug": {
"dialog": {

View File

@ -1846,7 +1846,10 @@
"22df63c393": "Los datos de la subemisión no están disponibles para su token.",
"067119985c": "Búsqueda de GitHub, p. cesionario:@yo es:abierto",
"1850fceac8": "{{value0}}/{{value1}} no se agrega a Orca. Agréguelo para comenzar a trabajar o ábralo en GitHub.",
"1aa7c952b9": "Project view"
"1aa7c952b9": "Project view",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
},
"slug": {
"dialog": {
@ -3819,7 +3822,8 @@
"1b0a156717": "Agents"
},
"WorktreeCardReviewDetailSection": {
"reviewHeader": "{{value0}} #{{value1}}"
"reviewHeader": "{{value0}} #{{value1}}",
"copyLink": "Copy link"
},
"WorktreeCardMeta": {
"3e65e11cc6": "Metadatos del espacio de trabajo",
@ -3846,7 +3850,13 @@
"checkingAutomationAvailability": "Comprobando disponibilidad de la automatización...",
"automationMissing": "La automatización ya no está disponible.",
"automationRunMissing": "El historial de ejecuciones ya no está disponible.",
"automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización."
"automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización.",
"moreIssueActions": "More issue actions",
"copyLink": "Copy link",
"copyLinkSuccess": "{{value0}} copied",
"copyLinkFailure": "Failed to copy link",
"issueLinkLabel": "Issue link",
"reviewLinkLabel": "{{value0}} link"
},
"WorktreeCardMetadataStatusBadges": {
"fe188062a1": "Estado: Abierto",

View File

@ -1846,7 +1846,10 @@
"22df63c393": "トークンではサブ Issue データを利用できません。",
"067119985c": "GitHub 検索、例:担当者:@me は:開いています",
"1850fceac8": "{{value0}}/{{value1}} は Orca に追加されません。これを追加して作業を開始するか、GitHub で開きます。",
"1aa7c952b9": "Project view"
"1aa7c952b9": "Project view",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
},
"slug": {
"dialog": {
@ -3800,7 +3803,8 @@
"1b0a156717": "Agents"
},
"WorktreeCardReviewDetailSection": {
"reviewHeader": "{{value0}} #{{value1}}"
"reviewHeader": "{{value0}} #{{value1}}",
"copyLink": "Copy link"
},
"WorktreeCardMeta": {
"3e65e11cc6": "ワークスペースのメタデータ",
@ -3827,7 +3831,13 @@
"checkingAutomationAvailability": "自動化の利用可否を確認中...",
"automationMissing": "自動化は利用できなくなりました。",
"automationRunMissing": "実行履歴は利用できなくなりました。",
"automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。"
"automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。",
"moreIssueActions": "More issue actions",
"copyLink": "Copy link",
"copyLinkSuccess": "{{value0}} copied",
"copyLinkFailure": "Failed to copy link",
"issueLinkLabel": "Issue link",
"reviewLinkLabel": "{{value0}} link"
},
"WorktreeCardMetadataStatusBadges": {
"fe188062a1": "状態: オープン",

View File

@ -1846,7 +1846,10 @@
"22df63c393": "토큰에 대한 하위 이슈 데이터를 사용할 수 없습니다.",
"067119985c": "GitHub 검색, 예: assignee:@me is:open",
"1850fceac8": "{{value0}}/{{value1}}이(가) Orca에 추가되어 있지 않습니다. 작업을 시작하려면 추가하거나 GitHub에서 여세요.",
"1aa7c952b9": "프로젝트 보기"
"1aa7c952b9": "프로젝트 보기",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
},
"slug": {
"dialog": {
@ -3800,7 +3803,8 @@
"1b0a156717": "Agents"
},
"WorktreeCardReviewDetailSection": {
"reviewHeader": "{{value0}} #{{value1}}"
"reviewHeader": "{{value0}} #{{value1}}",
"copyLink": "Copy link"
},
"WorktreeCardMeta": {
"3e65e11cc6": "워크스페이스 메타데이터",
@ -3827,7 +3831,13 @@
"checkingAutomationAvailability": "자동화 사용 가능 여부 확인 중...",
"automationMissing": "자동화를 더 이상 사용할 수 없습니다.",
"automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.",
"automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다."
"automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다.",
"moreIssueActions": "More issue actions",
"copyLink": "Copy link",
"copyLinkSuccess": "{{value0}} copied",
"copyLinkFailure": "Failed to copy link",
"issueLinkLabel": "Issue link",
"reviewLinkLabel": "{{value0}} link"
},
"WorktreeCardMetadataStatusBadges": {
"fe188062a1": "상태: 열림",

View File

@ -1846,7 +1846,10 @@
"22df63c393": "您的 Token 无法获取子议题数据。",
"067119985c": "GitHub 搜索,例如 assignee:@me is:open",
"1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作或在 GitHub 中打开。",
"1aa7c952b9": "项目视图"
"1aa7c952b9": "项目视图",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
},
"slug": {
"dialog": {
@ -3800,7 +3803,8 @@
"1b0a156717": "Agents"
},
"WorktreeCardReviewDetailSection": {
"reviewHeader": "{{value0}} #{{value1}}"
"reviewHeader": "{{value0}} #{{value1}}",
"copyLink": "Copy link"
},
"WorktreeCardMeta": {
"3e65e11cc6": "工作区元数据",
@ -3827,7 +3831,13 @@
"checkingAutomationAvailability": "正在检查自动化可用性...",
"automationMissing": "自动化已不可用。",
"automationRunMissing": "运行历史已不可用。",
"automationAvailabilityUnavailable": "无法检查自动化可用性。"
"automationAvailabilityUnavailable": "无法检查自动化可用性。",
"moreIssueActions": "More issue actions",
"copyLink": "Copy link",
"copyLinkSuccess": "{{value0}} copied",
"copyLinkFailure": "Failed to copy link",
"issueLinkLabel": "Issue link",
"reviewLinkLabel": "{{value0}} link"
},
"WorktreeCardMetadataStatusBadges": {
"fe188062a1": "状态:开放",