mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Fix Jira inventory ownership and composed query limits
This commit is contained in:
@@ -51,6 +51,7 @@ export type RepositoryApi = {
|
||||
| 'worktreeBasePath'
|
||||
| 'kind'
|
||||
| 'issueSourcePreference'
|
||||
| 'defaultJiraProject'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
|
||||
import { getJiraStatusCatalogScopeKey } from './task-page-jira-status-catalog'
|
||||
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
|
||||
afterEach(cleanup)
|
||||
function model(): TaskPageComposerActionsModel {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every field read by the status-controls component.
|
||||
return {
|
||||
jiraProjectFilter: null,
|
||||
jiraHasWorkspace: false,
|
||||
selectedJiraStatuses: ['Developed'],
|
||||
selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -93,3 +95,43 @@ it('hides statuses and query preview in linked mode without clearing selection',
|
||||
expect(state.selectJiraStatuses).not.toHaveBeenCalled()
|
||||
expect(state.selectedJiraStatuses).toEqual(['Developed'])
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'includes the mapped project and sprint selection %s in manual editing',
|
||||
async (currentSprint) => {
|
||||
const state = model()
|
||||
state.jiraCurrentSprint = currentSprint
|
||||
state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
render(<TaskPageJiraStatusFilters model={state} />)
|
||||
const sprintClause = currentSprint ? ' AND sprint in openSprints()' : ''
|
||||
const query = `project = "10" AND (assignee = currentUser() AND statusCategory != Done AND status in ("Developed")${sprintClause}) ORDER BY updated DESC`
|
||||
expect(screen.getByRole('textbox', { name: 'Status filter JQL' }).getAttribute('value')).toBe(
|
||||
query
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
|
||||
await waitFor(() => expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(query))
|
||||
}
|
||||
)
|
||||
|
||||
it('drops prior-project discovered statuses while retaining the selected status on project change', () => {
|
||||
const state = model()
|
||||
state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
state.jiraStatusCatalog = {
|
||||
scopeKey: getJiraStatusCatalogScopeKey(
|
||||
state.jiraTaskSourceScopeKey,
|
||||
state.selectedJiraSiteId,
|
||||
state.activeJiraPreset,
|
||||
state.jiraProjectFilter
|
||||
),
|
||||
items: [{ name: 'Alpha only', categoryKey: 'indeterminate' }]
|
||||
}
|
||||
const { rerender } = render(<TaskPageJiraStatusFilters model={state} />)
|
||||
expect(screen.getByRole('button', { name: 'Alpha only' })).toBeDefined()
|
||||
state.jiraProjectFilter = { id: '20', key: 'BET', name: 'Beta', siteId: 'site-b' }
|
||||
rerender(<TaskPageJiraStatusFilters model={state} />)
|
||||
expect(screen.queryByRole('button', { name: 'Alpha only' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
|
||||
'true'
|
||||
)
|
||||
expect(state.selectJiraStatuses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Input } from '@/components/ui/input'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { buildJiraPresetJql } from '../../../shared/jira-preset-jql'
|
||||
import { includeSelectedJiraStatuses } from './task-page-jira-status-catalog'
|
||||
import {
|
||||
getJiraStatusCatalogScopeKey,
|
||||
includeSelectedJiraStatuses
|
||||
} from './task-page-jira-status-catalog'
|
||||
import { restrictJiraQueryToProject } from '../../../shared/jira-project-reference'
|
||||
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
|
||||
|
||||
export function TaskPageJiraStatusFilters({
|
||||
@@ -34,15 +38,23 @@ export function TaskPageJiraStatusFilters({
|
||||
if (jiraHasWorkspace) {
|
||||
return null
|
||||
}
|
||||
const scopeKey = `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`
|
||||
const scopeKey = getJiraStatusCatalogScopeKey(
|
||||
jiraTaskSourceScopeKey,
|
||||
selectedJiraSiteId,
|
||||
activeJiraPreset,
|
||||
model.jiraProjectFilter
|
||||
)
|
||||
const statuses = includeSelectedJiraStatuses(
|
||||
jiraStatusCatalog?.scopeKey === scopeKey ? jiraStatusCatalog.items : [],
|
||||
selectedJiraStatuses
|
||||
)
|
||||
const preview = buildJiraPresetJql(activeJiraPreset, {
|
||||
const presetJql = buildJiraPresetJql(activeJiraPreset, {
|
||||
statuses: selectedJiraStatuses,
|
||||
currentSprint: jiraCurrentSprint
|
||||
})
|
||||
const preview = model.jiraProjectFilter
|
||||
? restrictJiraQueryToProject(presetJql, model.jiraProjectFilter)
|
||||
: presetJql
|
||||
const disabled =
|
||||
!jiraQuickFiltersReady ||
|
||||
jiraQuickFiltersLoading ||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { JiraIssue } from '../../../shared/jira-types'
|
||||
import type { JiraProjectReference } from '../../../shared/jira-project-reference'
|
||||
import { getJiraProjectSelectionKey } from './task-page-jira-project-selection'
|
||||
|
||||
export function getJiraStatusCatalogScopeKey(
|
||||
sourceScopeKey: string,
|
||||
selectedSiteId: string | null | undefined,
|
||||
preset: string,
|
||||
project?: JiraProjectReference | null
|
||||
): string {
|
||||
const scopeKey = `${sourceScopeKey}::${selectedSiteId}::${preset}`
|
||||
return project ? `${scopeKey}::${getJiraProjectSelectionKey(project)}` : scopeKey
|
||||
}
|
||||
|
||||
export type JiraStatusCatalogItem = { name: string; categoryKey: string }
|
||||
const STATUS_NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' })
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { useState } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
|
||||
import type { JiraProjectReference } from '../../../../../shared/jira-project-reference'
|
||||
import TaskPage from '../TaskPage'
|
||||
import { TaskPageJiraFilters } from './Filters'
|
||||
|
||||
const fixture = vi.hoisted(() => ({ loading: false }))
|
||||
vi.mock('../Surface', () => ({
|
||||
TaskPageSurface: ({ model }: { model: TaskPageComposerActionsModel }) => (
|
||||
<FiltersFixture baseModel={model} loading={fixture.loading} />
|
||||
)
|
||||
}))
|
||||
|
||||
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
const setJiraProjectFilter = vi.fn()
|
||||
const initialStore = useAppStore.getState()
|
||||
const initialApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
|
||||
|
||||
function FiltersFixture({
|
||||
baseModel,
|
||||
loading
|
||||
}: {
|
||||
baseModel: TaskPageComposerActionsModel
|
||||
loading: boolean
|
||||
}) {
|
||||
const [jiraHasWorkspace, setJiraHasWorkspace] = useState(false)
|
||||
const [jiraProjectFilter, setProject] = useState<JiraProjectReference | null>(project)
|
||||
const model = {
|
||||
...baseModel,
|
||||
jiraHasWorkspace,
|
||||
setJiraHasWorkspace,
|
||||
jiraProjectFilter,
|
||||
setJiraProjectFilter: (value: JiraProjectReference | null) => {
|
||||
setJiraProjectFilter(value)
|
||||
setProject(value)
|
||||
},
|
||||
setTaskResumeState: vi.fn(),
|
||||
jiraPresets: [{ id: 'all', label: 'All' }],
|
||||
jiraLoading: false,
|
||||
jiraSearchInput: '',
|
||||
setJiraSearchInput: vi.fn(),
|
||||
setAppliedJiraSearch: vi.fn(),
|
||||
activeJiraPreset: 'all',
|
||||
setActiveJiraPreset: vi.fn(),
|
||||
setJiraRefreshNonce: vi.fn(),
|
||||
jiraSavedFilters: [],
|
||||
jiraActiveSavedFilterId: null,
|
||||
jiraSavedFiltersReady: true,
|
||||
jiraSavedFiltersLoading: false,
|
||||
jiraSavedFiltersPending: false,
|
||||
jiraSavedFiltersError: null,
|
||||
retryJiraSavedFilters: vi.fn(),
|
||||
applyJiraSavedFilter: vi.fn().mockResolvedValue(true),
|
||||
createJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
|
||||
updateJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
|
||||
deleteJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
|
||||
clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined),
|
||||
selectedJiraStatuses: [],
|
||||
selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
|
||||
jiraQuickFiltersReady: true,
|
||||
jiraQuickFiltersLoading: false,
|
||||
jiraQuickFiltersPending: false,
|
||||
jiraQuickFiltersError: null,
|
||||
retryJiraQuickFilters: vi.fn(),
|
||||
jiraStatusCatalog: null,
|
||||
jiraTaskSourceScopeKey: 'fixture',
|
||||
selectedJiraSiteId: 'all',
|
||||
jiraProjectsLoading: loading,
|
||||
setNewJiraIssueOpen: vi.fn(),
|
||||
setNewJiraIssueTitle: vi.fn(),
|
||||
setNewJiraIssueBody: vi.fn(),
|
||||
setNewJiraIssueProjectId: vi.fn(),
|
||||
setNewJiraIssueProjectQuery: vi.fn(),
|
||||
setNewJiraIssueProjectCommandValue: vi.fn(),
|
||||
setNewJiraIssueTypeId: vi.fn(),
|
||||
sortedAvailableJiraProjects: [project]
|
||||
} satisfies TaskPageComposerActionsModel
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TaskPageJiraFilters model={model} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.loading = false
|
||||
useAppStore.setState(
|
||||
{
|
||||
...initialStore,
|
||||
settings: null,
|
||||
persistedUIReady: false,
|
||||
repos: [],
|
||||
taskPageData: {},
|
||||
checkLinearConnection: vi
|
||||
.fn<typeof initialStore.checkLinearConnection>()
|
||||
.mockResolvedValue(undefined),
|
||||
checkJiraConnection: vi
|
||||
.fn<typeof initialStore.checkJiraConnection>()
|
||||
.mockResolvedValue(undefined),
|
||||
refreshPreflightStatus: vi
|
||||
.fn<typeof initialStore.refreshPreflightStatus>()
|
||||
.mockResolvedValue(undefined)
|
||||
},
|
||||
true
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
gh: { viewer: vi.fn<typeof window.api.gh.viewer>().mockResolvedValue(null) }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialStore, true)
|
||||
if (initialApiDescriptor) {
|
||||
Object.defineProperty(window, 'api', initialApiDescriptor)
|
||||
} else {
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Jira project filters in Has Workspace mode', () => {
|
||||
it('disables project changes while linked and restores the existing choice on a preset', async () => {
|
||||
render(<TaskPage />)
|
||||
const picker = screen.getByRole('combobox', { name: 'Jira project' })
|
||||
expect(picker).toBeEnabled()
|
||||
expect(picker).toHaveTextContent('ALP · Alpha')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' }))
|
||||
expect(picker).toBeDisabled()
|
||||
fireEvent.click(picker)
|
||||
expect(screen.queryByRole('option')).not.toBeInTheDocument()
|
||||
expect(setJiraProjectFilter).not.toHaveBeenCalled()
|
||||
expect(picker).toHaveTextContent('ALP · Alpha')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All' }))
|
||||
expect(picker).toBeEnabled()
|
||||
expect(picker).toHaveTextContent('ALP · Alpha')
|
||||
fireEvent.click(picker)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'All Jira projects' }))
|
||||
expect(picker).toHaveTextContent('All Jira projects')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' }))
|
||||
expect(picker).toBeDisabled()
|
||||
expect(picker).toHaveTextContent('All Jira projects')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All' }))
|
||||
expect(picker).toBeEnabled()
|
||||
expect(picker).toHaveTextContent('All Jira projects')
|
||||
expect(setJiraProjectFilter).toHaveBeenCalledExactlyOnceWith(null)
|
||||
})
|
||||
|
||||
it('keeps project changes disabled during inventory loading', () => {
|
||||
fixture.loading = true
|
||||
render(<TaskPage />)
|
||||
expect(screen.getByRole('combobox', { name: 'Jira project' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -62,7 +62,7 @@ export function TaskPageJiraFilters({
|
||||
onChange={model.setJiraProjectFilter}
|
||||
label={translate('components.jiraProjectPicker.label', 'Jira project')}
|
||||
emptyLabel={translate('components.jiraProjectPicker.all', 'All Jira projects')}
|
||||
disabled={jiraProjectsLoading}
|
||||
disabled={jiraProjectsLoading || jiraHasWorkspace}
|
||||
/>
|
||||
</div>
|
||||
{jiraPresets.map((preset) => {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraProject } from '../../../shared/jira-types'
|
||||
import type { RuntimeJiraSettings } from '@/runtime/runtime-jira-client'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { normalizeTaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { useJiraProjectInventory } from './use-jira-project-inventory'
|
||||
|
||||
const { status, projects } = vi.hoisted(() => ({ status: vi.fn(), projects: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-jira-client', () => ({ jiraStatus: status, jiraListProjects: projects }))
|
||||
vi.mock('@/runtime/runtime-jira-project-client', () => ({
|
||||
jiraStatusForTarget: status,
|
||||
jiraListProjectsForTarget: projects
|
||||
}))
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
status.mockReset()
|
||||
@@ -16,21 +20,21 @@ beforeEach(() => {
|
||||
|
||||
describe('Jira project inventory ownership', () => {
|
||||
it('ignores a late result from the previous account host', async () => {
|
||||
const local: RuntimeJiraSettings = { kind: 'local' }
|
||||
const remote: RuntimeJiraSettings = { kind: 'environment', environmentId: 'owner' }
|
||||
const local: RuntimeClientTarget = { kind: 'local' }
|
||||
const remote: RuntimeClientTarget = { kind: 'environment', environmentId: 'owner' }
|
||||
const localProject = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'local-site' }
|
||||
const remoteProject = { ...localProject, siteId: 'remote-site' }
|
||||
let finishLocal: (value: JiraProject[]) => void = () => {
|
||||
throw new Error('Local request has not started')
|
||||
}
|
||||
projects.mockImplementation((source: RuntimeJiraSettings) =>
|
||||
projects.mockImplementation((source: RuntimeClientTarget) =>
|
||||
source === local
|
||||
? new Promise<JiraProject[]>((resolve) => {
|
||||
finishLocal = resolve
|
||||
})
|
||||
: Promise.resolve([remoteProject])
|
||||
)
|
||||
const initialProps: { source: RuntimeJiraSettings } = { source: local }
|
||||
const initialProps: { source: RuntimeClientTarget } = { source: local }
|
||||
const { result, rerender } = renderHook(({ source }) => useJiraProjectInventory(source, true), {
|
||||
initialProps
|
||||
})
|
||||
@@ -43,10 +47,32 @@ describe('Jira project inventory ownership', () => {
|
||||
|
||||
it('does not discover projects when the account is disconnected', async () => {
|
||||
status.mockResolvedValue({ connected: false })
|
||||
const local: RuntimeJiraSettings = { kind: 'local' }
|
||||
const local: RuntimeClientTarget = { kind: 'local' }
|
||||
const { result } = renderHook(() => useJiraProjectInventory(local, true))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.connected).toBe(false)
|
||||
expect(projects).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['local', { kind: 'local' }],
|
||||
['ssh:builder', { kind: 'local' }],
|
||||
['runtime:owner', { kind: 'environment', environmentId: 'owner' }]
|
||||
] as const)('routes the %s task source through its account owner', async (hostId, target) => {
|
||||
const source = normalizeTaskSourceContext({ provider: 'jira', projectId: 'project', hostId })
|
||||
if (!source) {
|
||||
throw new Error('Expected a valid Jira task source')
|
||||
}
|
||||
projects.mockResolvedValue([])
|
||||
const { result } = renderHook(() => useJiraProjectInventory(source, true))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(status).toHaveBeenCalledWith(target)
|
||||
expect(projects).toHaveBeenCalledWith(target, 'all')
|
||||
})
|
||||
|
||||
it('does not read any owner when inventory is disabled', () => {
|
||||
renderHook(() => useJiraProjectInventory({ kind: 'local' }, false))
|
||||
expect(status).not.toHaveBeenCalled()
|
||||
expect(projects).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JiraProject } from '../../../shared/jira-types'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target'
|
||||
import {
|
||||
jiraListProjects,
|
||||
jiraStatus,
|
||||
type RuntimeJiraSettings
|
||||
} from '@/runtime/runtime-jira-client'
|
||||
jiraListProjectsForTarget,
|
||||
jiraStatusForTarget
|
||||
} from '@/runtime/runtime-jira-project-client'
|
||||
|
||||
export function useJiraProjectInventory(source: RuntimeJiraSettings, enabled: boolean) {
|
||||
type JiraProjectInventorySource = RuntimeClientTarget | TaskSourceContext
|
||||
|
||||
export function useJiraProjectInventory(source: JiraProjectInventorySource, enabled: boolean) {
|
||||
const [inventory, setInventory] = useState<{
|
||||
source: RuntimeJiraSettings
|
||||
source: JiraProjectInventorySource
|
||||
projects: JiraProject[]
|
||||
connected: boolean
|
||||
error: string | null
|
||||
@@ -18,11 +22,12 @@ export function useJiraProjectInventory(source: RuntimeJiraSettings, enabled: bo
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void jiraStatus(source)
|
||||
const target = source.kind === 'task-source' ? taskSourceRuntimeTarget(source) : source
|
||||
void jiraStatusForTarget(target)
|
||||
.then(async (status) => ({
|
||||
source,
|
||||
connected: status.connected,
|
||||
projects: status.connected ? await jiraListProjects(source, 'all') : [],
|
||||
projects: status.connected ? await jiraListProjectsForTarget(target, 'all') : [],
|
||||
error: null
|
||||
}))
|
||||
.then((next) => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects'
|
||||
import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects'
|
||||
import { restrictJiraQueryToProject } from '../../../shared/jira-project-reference'
|
||||
import { buildJiraPresetJql } from '../../../shared/jira-preset-jql'
|
||||
import { buildJiraTextMatchJql } from '../../../shared/jira-search-input-jql'
|
||||
|
||||
const listJiraIssues = vi.fn().mockResolvedValue([])
|
||||
const searchJiraIssues = vi.fn().mockResolvedValue([])
|
||||
@@ -37,6 +40,7 @@ function createModel(): TaskPageLinearCollectionEffectsModel {
|
||||
setAppliedJiraSearch: vi.fn(),
|
||||
activeJiraPreset: 'assigned',
|
||||
jiraCurrentSprint: false,
|
||||
jiraProjectFilter: null,
|
||||
jiraRefreshNonce: 0,
|
||||
jiraActiveSavedFilterId: null,
|
||||
jiraSavedFilters: [],
|
||||
@@ -109,6 +113,46 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps mapped saved JQL exact with sprint filtering and ignores persisted statuses', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = 'filter = 12345 ORDER BY created ASC'
|
||||
model.jiraActiveSavedFilterId = 'saved'
|
||||
model.jiraSavedFilters = [{ id: 'saved', name: 'Saved', jql: model.appliedJiraSearch }]
|
||||
model.jiraCurrentSprint = true
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
searchJiraIssues.mockRejectedValue(new Error('Error 400: Invalid saved query'))
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ details: 'Invalid saved query' })
|
||||
)
|
||||
)
|
||||
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(
|
||||
'project = "10" AND ((filter = 12345) AND sprint in openSprints()) ORDER BY created ASC',
|
||||
50,
|
||||
{ sourceContext: null, siteId: 'site-a', force: false }
|
||||
)
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies mapped text before adding sprint filtering without persisted statuses', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = 'fix login'
|
||||
model.jiraCurrentSprint = true
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(
|
||||
'project = "10" AND ((text ~ "fix login*") AND sprint in openSprints())',
|
||||
50,
|
||||
{ sourceContext: null, siteId: 'site-a', force: false }
|
||||
)
|
||||
)
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a saved query that exceeds the request limit after sprint composition', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = `key in (${Array.from({ length: 1030 }, (_, index) => `APP-${index + 1}`).join(',')})`
|
||||
@@ -125,6 +169,165 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks the saved query limit after both sprint and project restrictions', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = `summary ~ "${'x'.repeat(8140)}"`
|
||||
model.jiraActiveSavedFilterId = 'large'
|
||||
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: model.appliedJiraSearch }]
|
||||
model.jiraCurrentSprint = true
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringContaining('too long') })
|
||||
)
|
||||
)
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'saved JQL',
|
||||
query: `summary ~ "${'x'.repeat(8160)}"`,
|
||||
validQuery: `summary ~ "${'x'.repeat(8159)}"`,
|
||||
saved: true
|
||||
},
|
||||
{
|
||||
name: 'manual JQL',
|
||||
query: `summary ~ "${'x'.repeat(8160)}"`,
|
||||
validQuery: `summary ~ "${'x'.repeat(8159)}"`,
|
||||
saved: false
|
||||
},
|
||||
{ name: 'plain text', query: 'x'.repeat(8162), validQuery: 'x'.repeat(8161), saved: false },
|
||||
{
|
||||
name: 'UTF-8 JQL',
|
||||
query: `summary ~ "${'界'.repeat(2720)}"`,
|
||||
validQuery: `summary ~ "${'界'.repeat(2719)}xx"`,
|
||||
saved: false
|
||||
}
|
||||
])(
|
||||
'rejects project-only overflow for $name and recovers at 8192 bytes',
|
||||
async ({ query, validQuery, saved }) => {
|
||||
const model = createModel()
|
||||
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
model.appliedJiraSearch = query
|
||||
model.jiraProjectFilter = project
|
||||
if (saved) {
|
||||
model.jiraActiveSavedFilterId = 'large'
|
||||
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: query }]
|
||||
}
|
||||
const jql = query.includes('~') ? query : buildJiraTextMatchJql(query)
|
||||
expect(new TextEncoder().encode(query).length).toBeLessThan(8192)
|
||||
expect(new TextEncoder().encode(restrictJiraQueryToProject(jql, project)).length).toBe(8193)
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith({
|
||||
title: 'The Jira query is too long. Shorten the query or clear filters.',
|
||||
details: null
|
||||
})
|
||||
)
|
||||
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
|
||||
expect(model.setJiraIssues).toHaveBeenLastCalledWith([])
|
||||
expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null)
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
|
||||
const validJql = query.includes('~') ? validQuery : buildJiraTextMatchJql(validQuery)
|
||||
const scopedValidJql = restrictJiraQueryToProject(validJql, project)
|
||||
expect(new TextEncoder().encode(scopedValidJql).length).toBe(8192)
|
||||
model.appliedJiraSearch = validQuery
|
||||
if (saved) {
|
||||
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: validQuery }]
|
||||
}
|
||||
rerender()
|
||||
await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false))
|
||||
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedValidJql, 50, {
|
||||
sourceContext: null,
|
||||
siteId: 'site-a',
|
||||
force: false
|
||||
})
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'escaped',
|
||||
statuses: Array.from({ length: 50 }, (_, index) => `${index}${'"'.repeat(124)}`)
|
||||
},
|
||||
{
|
||||
name: 'UTF-8',
|
||||
statuses: Array.from({ length: 50 }, (_, index) => `${index}${'界'.repeat(126)}`)
|
||||
}
|
||||
])(
|
||||
'rejects an oversized mapped preset with $name status names and recovers',
|
||||
async ({ statuses }) => {
|
||||
const model = createModel()
|
||||
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
model.activeJiraPreset = 'all'
|
||||
model.jiraProjectFilter = project
|
||||
model.selectedJiraStatuses = statuses
|
||||
expect(statuses.every((status) => status.length <= 128)).toBe(true)
|
||||
const query = restrictJiraQueryToProject(buildJiraPresetJql('all', { statuses }), project)
|
||||
expect(new TextEncoder().encode(query).length).toBeGreaterThan(8192)
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith({
|
||||
title: 'The Jira query is too long. Shorten the query or clear filters.',
|
||||
details: null
|
||||
})
|
||||
)
|
||||
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
rerender()
|
||||
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
|
||||
expect(searchJiraIssues).toHaveBeenLastCalledWith(
|
||||
'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC',
|
||||
50,
|
||||
{ sourceContext: null, siteId: 'site-a', force: false }
|
||||
)
|
||||
await waitFor(() => expect(model.setJiraError).toHaveBeenLastCalledWith(null))
|
||||
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports an oversized manual query without project or sprint filters', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = `summary ~ "${'x'.repeat(8181)}"`
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith({
|
||||
title: 'The Jira query is too long. Shorten the query or clear filters.',
|
||||
details: null
|
||||
})
|
||||
)
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('ignores a mapped preset limit rejection after changing the source', async () => {
|
||||
const model = createModel()
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
model.selectedJiraStatuses = Array.from(
|
||||
{ length: 50 },
|
||||
(_, index) => `${index}${'"'.repeat(124)}`
|
||||
)
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
model.taskSource = 'linear'
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(model.setJiraError).toHaveBeenCalledExactlyOnceWith(null)
|
||||
expect(model.setJiraIssues).not.toHaveBeenCalled()
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads linked keys directly and does not reread them for local search text', async () => {
|
||||
const model = createModel()
|
||||
model.jiraHasWorkspace = true
|
||||
@@ -260,21 +463,148 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes the All preset to the mapped project and account before reading issues', async () => {
|
||||
it.each([
|
||||
{
|
||||
statuses: [],
|
||||
currentSprint: false,
|
||||
query: 'project = "10" AND (statusCategory != Done) ORDER BY updated DESC'
|
||||
},
|
||||
{
|
||||
statuses: ['In Progress', 'Blocked'],
|
||||
currentSprint: false,
|
||||
query:
|
||||
'project = "10" AND (statusCategory != Done AND status in ("In Progress", "Blocked")) ORDER BY updated DESC'
|
||||
},
|
||||
{
|
||||
statuses: [],
|
||||
currentSprint: true,
|
||||
query:
|
||||
'project = "10" AND (statusCategory != Done AND sprint in openSprints()) ORDER BY updated DESC'
|
||||
},
|
||||
{
|
||||
statuses: ['Blocked'],
|
||||
currentSprint: true,
|
||||
query:
|
||||
'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC'
|
||||
}
|
||||
])(
|
||||
'scopes statuses $statuses and sprint $currentSprint to the mapped project and owner',
|
||||
async ({ statuses, currentSprint, query }) => {
|
||||
const model = createModel()
|
||||
model.jiraTaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'source-project',
|
||||
hostId: 'runtime:owner'
|
||||
}
|
||||
model.activeJiraPreset = 'all'
|
||||
model.selectedJiraStatuses = statuses
|
||||
model.jiraCurrentSprint = currentSprint
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(searchJiraIssues).toHaveBeenCalledWith(query, 50, {
|
||||
sourceContext: model.jiraTaskSourceContext,
|
||||
siteId: 'site-a',
|
||||
force: false
|
||||
})
|
||||
)
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false))
|
||||
if (statuses.length === 0) {
|
||||
expect(model.setJiraStatusCatalog).toHaveBeenCalledWith({
|
||||
scopeKey: 'fixture::site-1::all::site-a:10',
|
||||
items: []
|
||||
})
|
||||
} else {
|
||||
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores the project mapping for linked issues and restores it when leaving linked mode', async () => {
|
||||
const model = createModel()
|
||||
model.activeJiraPreset = 'all'
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(searchJiraIssues).toHaveBeenCalledWith(
|
||||
'project = "10" AND (statusCategory != Done) ORDER BY updated DESC',
|
||||
50,
|
||||
{ sourceContext: null, siteId: 'site-a', force: false }
|
||||
)
|
||||
)
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
model.jiraCurrentSprint = true
|
||||
model.jiraHasWorkspace = true
|
||||
model.linkedJiraIssueRefs = [{ key: 'APP-1', siteId: 'site-1' }]
|
||||
model.linkedJiraIssueRefsSignature = JSON.stringify(model.linkedJiraIssueRefs)
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
|
||||
expect(searchJiraIssues).toHaveBeenLastCalledWith('key = APP-1', 1, {
|
||||
sourceContext: null,
|
||||
siteId: 'site-1',
|
||||
force: false
|
||||
})
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
|
||||
expect(model.selectedJiraStatuses).toEqual(['Blocked'])
|
||||
expect(model.jiraCurrentSprint).toBe(true)
|
||||
model.jiraHasWorkspace = false
|
||||
rerender()
|
||||
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
|
||||
expect(searchJiraIssues).toHaveBeenLastCalledWith(
|
||||
'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC',
|
||||
50,
|
||||
{ sourceContext: null, siteId: 'site-a', force: false }
|
||||
)
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
|
||||
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['release candidate', 'project = "10" AND (release candidate)'],
|
||||
[
|
||||
'filter = 12345 ORDER BY created ASC',
|
||||
'project = "10" AND (filter = 12345) ORDER BY created ASC'
|
||||
]
|
||||
])(
|
||||
'keeps saved JQL exact in the mapped project and restores its preset after exit: %s',
|
||||
async (query, scopedQuery) => {
|
||||
const model = createModel()
|
||||
const filter = { id: 'saved', name: 'Saved work', jql: query }
|
||||
model.jiraTaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'source-project',
|
||||
hostId: 'runtime:owner'
|
||||
}
|
||||
model.activeJiraPreset = 'all'
|
||||
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
model.jiraSavedFilters = [filter]
|
||||
model.jiraActiveSavedFilterId = filter.id
|
||||
model.appliedJiraSearch = query
|
||||
searchJiraIssues.mockRejectedValueOnce(new Error('Error 400: JQL syntax invalid'))
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() =>
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ details: 'JQL syntax invalid' })
|
||||
)
|
||||
)
|
||||
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedQuery, 50, {
|
||||
sourceContext: model.jiraTaskSourceContext,
|
||||
siteId: 'site-a',
|
||||
force: false
|
||||
})
|
||||
expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null)
|
||||
|
||||
model.jiraActiveSavedFilterId = null
|
||||
model.appliedJiraSearch = ''
|
||||
rerender()
|
||||
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
|
||||
expect(searchJiraIssues).toHaveBeenLastCalledWith(
|
||||
'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC',
|
||||
50,
|
||||
{ sourceContext: model.jiraTaskSourceContext, siteId: 'site-a', force: false }
|
||||
)
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows the JQL rejection with its text matches and clears it for the next search', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = 'project = NOPE'
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isRuntimeProviderSearchQueryWithinLimit } from '@/runtime/runtime-provider-search-bounds'
|
||||
import { withCurrentSprintClause } from '../../../shared/jira-jql-predicate'
|
||||
import { buildJiraStatusCatalog } from './task-page-jira-status-catalog'
|
||||
import {
|
||||
buildJiraStatusCatalog,
|
||||
getJiraStatusCatalogScopeKey
|
||||
} from './task-page-jira-status-catalog'
|
||||
import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
@@ -103,7 +106,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce
|
||||
previousJiraRefreshNonceRef.current = jiraRefreshNonce
|
||||
let cancelled = false
|
||||
let sprintQueryTooLong = false
|
||||
let queryTooLong = false
|
||||
setJiraLoading(true)
|
||||
setJiraError(null)
|
||||
setJiraJqlRejection(null)
|
||||
@@ -111,6 +114,17 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
const trimmed = jiraRemoteSearch.trim()
|
||||
const savedFilter = jiraSavedFilters.find((filter) => filter.id === jiraActiveSavedFilterId)
|
||||
const exactSavedJql = savedFilter?.jql === trimmed
|
||||
const searchWithinLimit = async (query: string) => {
|
||||
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
|
||||
queryTooLong = true
|
||||
throw new Error('Query exceeds the request limit.')
|
||||
}
|
||||
return searchJiraIssues(query, JIRA_ITEM_LIMIT, {
|
||||
sourceContext: jiraTaskSourceContext,
|
||||
...(jiraProjectFilter ? { siteId: jiraProjectFilter.siteId } : {}),
|
||||
force
|
||||
})
|
||||
}
|
||||
const request = jiraHasWorkspace
|
||||
? readLinkedJiraIssues(linkedJiraIssueRefs, searchJiraIssues, jiraTaskSourceContext, force)
|
||||
: trimmed.length > 0
|
||||
@@ -121,33 +135,19 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
const query = jiraProjectFilter
|
||||
? restrictJiraQueryToProject(sprintJql, jiraProjectFilter)
|
||||
: sprintJql
|
||||
if (jiraCurrentSprint && !isRuntimeProviderSearchQueryWithinLimit(query)) {
|
||||
sprintQueryTooLong = true
|
||||
throw new Error('Query exceeds the request limit after sprint filtering.')
|
||||
}
|
||||
return searchJiraIssues(query, JIRA_ITEM_LIMIT, {
|
||||
sourceContext: jiraTaskSourceContext,
|
||||
...(jiraProjectFilter ? { siteId: jiraProjectFilter.siteId } : {}),
|
||||
force
|
||||
})
|
||||
return searchWithinLimit(query)
|
||||
},
|
||||
exactSavedJql ? 'jql' : 'input'
|
||||
).then((result) => ({ ...result, error: null }))
|
||||
: jiraProjectFilter
|
||||
? searchJiraIssues(
|
||||
? searchWithinLimit(
|
||||
restrictJiraQueryToProject(
|
||||
buildJiraPresetJql(activeJiraPreset, {
|
||||
statuses: selectedJiraStatuses,
|
||||
currentSprint: jiraCurrentSprint
|
||||
}),
|
||||
jiraProjectFilter
|
||||
),
|
||||
JIRA_ITEM_LIMIT,
|
||||
{
|
||||
sourceContext: jiraTaskSourceContext,
|
||||
siteId: jiraProjectFilter.siteId,
|
||||
force
|
||||
}
|
||||
)
|
||||
).then((issues) => ({ issues, jqlRejection: null, error: null }))
|
||||
: listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, {
|
||||
sourceContext: jiraTaskSourceContext,
|
||||
@@ -170,7 +170,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
setJiraError(result.error)
|
||||
if (!jiraHasWorkspace && !trimmed && selectedJiraStatuses.length === 0) {
|
||||
setJiraStatusCatalog({
|
||||
scopeKey: `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`,
|
||||
scopeKey: getJiraStatusCatalogScopeKey(
|
||||
jiraTaskSourceScopeKey,
|
||||
selectedJiraSiteId,
|
||||
activeJiraPreset,
|
||||
jiraProjectFilter
|
||||
),
|
||||
items: buildJiraStatusCatalog(issues)
|
||||
})
|
||||
}
|
||||
@@ -205,11 +210,11 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
const failureState = createTaskPageJiraLoadFailureState(err)
|
||||
setJiraIssues(failureState.issues)
|
||||
setJiraError(
|
||||
sprintQueryTooLong
|
||||
queryTooLong
|
||||
? {
|
||||
title: translate(
|
||||
'jira.board.queryTooLong',
|
||||
'The Jira query is too long with Current sprint selected. Shorten the query or turn off Current sprint.'
|
||||
'The Jira query is too long. Shorten the query or clear filters.'
|
||||
),
|
||||
details: null
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ function fixture(
|
||||
jiraConnected: true,
|
||||
selectedJiraSiteId: 'site-1',
|
||||
taskSource: 'jira',
|
||||
repos: [],
|
||||
selectedRepos: [],
|
||||
pageData: {},
|
||||
jiraTaskSourceContext: sourceContext
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated hook reads only the listed pipeline fields.
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope'
|
||||
import { useTaskPageJiraListState } from './use-task-page-jira-list-state'
|
||||
import { useTaskPageStoreBindings } from './use-task-page-store-bindings'
|
||||
import { useTaskPageRepoSelection } from './use-task-page-repo-selection'
|
||||
import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts'
|
||||
import { useTaskPageSourceAvailability } from './use-task-page-source-availability'
|
||||
import { useTaskPageProviderState } from './use-task-page-provider-state'
|
||||
import { useTaskPageGitHubListState } from './use-task-page-github-list-state'
|
||||
import { useTaskPageGitHubDetail } from './use-task-page-github-detail'
|
||||
import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation'
|
||||
import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft'
|
||||
import { useTaskPageDetailRouting } from './use-task-page-detail-routing'
|
||||
import { useTaskPageLinearViewState } from './use-task-page-linear-view-state'
|
||||
const { readSavedFilters, mutateSavedFilters, readQuickFilters, writeQuickFilters } = vi.hoisted(
|
||||
() => ({
|
||||
readSavedFilters: vi.fn(),
|
||||
mutateSavedFilters: vi.fn(),
|
||||
readQuickFilters: vi.fn(),
|
||||
writeQuickFilters: vi.fn()
|
||||
})
|
||||
)
|
||||
vi.mock('@/runtime/runtime-jira-saved-filter-state', () => ({
|
||||
readJiraSavedFilterState: readSavedFilters,
|
||||
mutateJiraSavedFilters: mutateSavedFilters
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-jira-quick-filter-state', () => ({
|
||||
readJiraQuickFilterState: readQuickFilters,
|
||||
writeJiraQuickFilterState: writeQuickFilters
|
||||
}))
|
||||
const originalState = useAppStore.getState()
|
||||
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
const filter = { id: 'saved', name: 'Saved work', jql: 'filter = 12345' }
|
||||
const repo: Repo = {
|
||||
id: 'repo',
|
||||
path: '/repo',
|
||||
@@ -14,7 +46,40 @@ const repo: Repo = {
|
||||
defaultJiraProject: project,
|
||||
kind: 'folder'
|
||||
}
|
||||
afterEach(cleanup)
|
||||
const sshProject = { ...project, id: '20', key: 'BRV', name: 'Bravo' }
|
||||
const sshRepo: Repo = {
|
||||
...repo,
|
||||
path: '/ssh/repo',
|
||||
connectionId: 'builder',
|
||||
defaultJiraProject: sshProject
|
||||
}
|
||||
|
||||
function useComposedJiraListState() {
|
||||
const store = useTaskPageStoreBindings()
|
||||
const selection = useTaskPageRepoSelection(store)
|
||||
const hosts = useTaskPageRuntimeHosts(selection)
|
||||
const availability = useTaskPageSourceAvailability(hosts)
|
||||
const provider = useTaskPageProviderState(availability)
|
||||
const githubList = useTaskPageGitHubListState(provider)
|
||||
const githubDetail = useTaskPageGitHubDetail(githubList)
|
||||
const reconciled = useTaskPageGitHubCacheReconciliation(githubDetail)
|
||||
const draft = useTaskPageGitHubIssueDraft(reconciled)
|
||||
const routing = useTaskPageDetailRouting(draft)
|
||||
const linear = useTaskPageLinearViewState(routing)
|
||||
return useTaskPageJiraListState(linear)
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(originalState, true)
|
||||
localStorage.removeItem('orca.jira.has-workspace.v1')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
readSavedFilters.mockResolvedValue({ filters: [filter], activeFilterId: filter.id })
|
||||
mutateSavedFilters.mockResolvedValue({ filters: [filter], activeFilterId: null })
|
||||
readQuickFilters.mockResolvedValue({ statuses: ['Blocked'] })
|
||||
writeQuickFilters.mockResolvedValue({ statuses: [] })
|
||||
})
|
||||
describe('repository-scoped Jira Tasks', () => {
|
||||
it('uses an explicit folder repository default and allows the All-project override', () => {
|
||||
const { result } = renderHook(() =>
|
||||
@@ -37,4 +102,157 @@ describe('repository-scoped Jira Tasks', () => {
|
||||
rerender({ host: 'local', site: 'site-a' })
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
})
|
||||
|
||||
it('uses the explicitly selected SSH owner before a duplicate preselected repository ID', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope([repo, sshRepo], [sshRepo], repo.id, 'local', project.siteId)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toEqual(sshProject)
|
||||
})
|
||||
|
||||
it('keeps a unique preselection ahead of a different previously selected repository', () => {
|
||||
const previouslySelected: Repo = { ...repo, id: 'previous', defaultJiraProject: sshProject }
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope(
|
||||
[repo, previouslySelected],
|
||||
[previouslySelected],
|
||||
repo.id,
|
||||
'local',
|
||||
project.siteId
|
||||
)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
})
|
||||
|
||||
it('falls back to the single selected repository when the preselected ID is missing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope([repo], [repo], 'missing', 'local', project.siteId)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
})
|
||||
|
||||
it('falls back when a unique different-ID preselection belongs to another Jira account', () => {
|
||||
const runtimeRepo: Repo = { ...repo, id: 'remote', executionHostId: 'runtime:remote' }
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope(
|
||||
[repo, runtimeRepo],
|
||||
[repo],
|
||||
runtimeRepo.id,
|
||||
'local',
|
||||
project.siteId
|
||||
)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
})
|
||||
|
||||
it('does not borrow a compatible default when the explicit matching owner uses another account', () => {
|
||||
const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' }
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope(
|
||||
[repo, runtimeRepo],
|
||||
[runtimeRepo],
|
||||
repo.id,
|
||||
'local',
|
||||
project.siteId
|
||||
)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
})
|
||||
|
||||
it('does not use a different selected repository to resolve ambiguous preselection', () => {
|
||||
const previouslySelected: Repo = { ...repo, id: 'previous' }
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope(
|
||||
[repo, sshRepo, previouslySelected],
|
||||
[previouslySelected],
|
||||
repo.id,
|
||||
'local',
|
||||
project.siteId
|
||||
)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
})
|
||||
|
||||
it('resets the All-project override when the same repository ID changes execution owner', () => {
|
||||
const initialProps: { selected: readonly Repo[] } = { selected: [repo] }
|
||||
const { result, rerender } = renderHook(
|
||||
({ selected }) =>
|
||||
useTaskPageJiraProjectScope([repo, sshRepo], selected, undefined, 'local', project.siteId),
|
||||
{ initialProps }
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
act(() => result.current.setJiraProjectFilter(null))
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
rerender({ selected: [sshRepo] })
|
||||
expect(result.current.jiraProjectFilter).toEqual(sshProject)
|
||||
})
|
||||
|
||||
it.each([{ selected: [] }, { selected: [repo, sshRepo] }])(
|
||||
'refuses an ambiguous preselected repository ID without a single selected owner (%#)',
|
||||
({ selected }) => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope([repo, sshRepo], selected, repo.id, 'local', project.siteId)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not use the Jira account owner to choose between local and runtime repositories', () => {
|
||||
const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' }
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraProjectScope([repo, runtimeRepo], [], repo.id, 'local', project.siteId)
|
||||
)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps duplicate execution owners ambiguous through the actual Tasks list-state pipeline', () => {
|
||||
const localGitRepo: Repo = { ...repo, kind: 'git' }
|
||||
const sshGitRepo: Repo = { ...sshRepo, kind: 'git' }
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
repos: [localGitRepo, sshGitRepo],
|
||||
taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id },
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' },
|
||||
jiraStatusChecked: true,
|
||||
jiraStatusContextKey: getProviderRuntimeContextKey(null)
|
||||
})
|
||||
const { result } = renderHook(useComposedJiraListState)
|
||||
expect(result.current.selectedRepos).toEqual([localGitRepo, sshGitRepo])
|
||||
expect(result.current.accountBackedTaskSourceHostId).toBe('local')
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps linked mode, saved filters, and the project override in the composed list state', async () => {
|
||||
localStorage.removeItem('orca.jira.has-workspace.v1')
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
repos: [repo],
|
||||
taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id },
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' },
|
||||
jiraStatusChecked: true,
|
||||
jiraStatusContextKey: getProviderRuntimeContextKey(null)
|
||||
})
|
||||
const { result } = renderHook(useComposedJiraListState)
|
||||
act(() => result.current.setTaskResumeApplied(true))
|
||||
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersReady).toBe(true))
|
||||
expect(readSavedFilters).toHaveBeenCalledWith({ kind: 'local' })
|
||||
expect(result.current.jiraActiveSavedFilterId).toBe(filter.id)
|
||||
expect(result.current.appliedJiraSearch).toBe(filter.jql)
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
|
||||
expect(result.current.jiraProjectFilter).toEqual(project)
|
||||
act(() => result.current.setJiraProjectFilter(null))
|
||||
act(() => result.current.setJiraHasWorkspace(true))
|
||||
expect(result.current.jiraHasWorkspace).toBe(true)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
await act(async () => result.current.clearJiraSavedFilterSelection())
|
||||
expect(mutateSavedFilters).toHaveBeenCalledWith({ kind: 'local' }, { kind: 'select', id: null })
|
||||
expect(result.current.jiraActiveSavedFilterId).toBeNull()
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
act(() => result.current.setJiraHasWorkspace(false))
|
||||
expect(result.current.jiraHasWorkspace).toBe(false)
|
||||
expect(result.current.jiraProjectFilter).toBeNull()
|
||||
expect(repo.defaultJiraProject).toEqual(project)
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Repo } from '../../../shared/repo-types'
|
||||
import type { JiraProjectReference } from '../../../shared/jira-project-reference'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { resolveJiraSourceHostId } from '../../../shared/jira-source-host'
|
||||
import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
|
||||
|
||||
export function useTaskPageJiraProjectScope(
|
||||
repos: readonly Repo[],
|
||||
@@ -11,15 +12,23 @@ export function useTaskPageJiraProjectScope(
|
||||
hostId: ExecutionHostId,
|
||||
siteId: string | null | undefined
|
||||
) {
|
||||
const preselectedRepos = preselectedRepoId
|
||||
? repos.filter((entry) => entry.id === preselectedRepoId)
|
||||
: []
|
||||
const selectedRepo = selectedRepos.length === 1 ? selectedRepos[0] : null
|
||||
const preselectedRepo = preselectedRepos.length === 1 ? preselectedRepos[0] : null
|
||||
const repo =
|
||||
(preselectedRepoId
|
||||
? repos.find(
|
||||
(entry) =>
|
||||
entry.id === preselectedRepoId &&
|
||||
resolveJiraSourceHostId({ workspaceHostId: getRepoExecutionHostId(entry) }) === hostId
|
||||
)
|
||||
: null) ?? (selectedRepos.length === 1 ? selectedRepos[0] : null)
|
||||
const scopeKey = `${hostId}:${repo?.id ?? ''}:${siteId ?? ''}`
|
||||
!preselectedRepoId || selectedRepo?.id === preselectedRepoId
|
||||
? selectedRepo
|
||||
: preselectedRepos.length > 1
|
||||
? null
|
||||
: preselectedRepo &&
|
||||
resolveJiraSourceHostId({
|
||||
workspaceHostId: getRepoExecutionHostId(preselectedRepo)
|
||||
}) === hostId
|
||||
? preselectedRepo
|
||||
: selectedRepo
|
||||
const scopeKey = JSON.stringify([hostId, repo ? getRepoHostIdentity(repo) : null, siteId ?? null])
|
||||
const [override, setOverride] = useState<{
|
||||
scopeKey: string
|
||||
project: JiraProjectReference | null
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { RepositoryJiraProjectSection } from '@/components/settings/RepositoryJiraProjectSection'
|
||||
import { useWorkItemSourceSearch } from '@/components/new-workspace/use-work-item-source-search'
|
||||
import { useJiraProjectInventory } from '@/components/use-jira-project-inventory'
|
||||
import { getRepoWorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
import type { JiraIssue, JiraProject } from '../../../../shared/jira-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
|
||||
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
|
||||
import {
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
} from '../../../../shared/protocol-version'
|
||||
import * as decisions from './composer-decisions'
|
||||
import { useComposerTargetState } from './composer-target-state'
|
||||
import { useComposerExternalSync } from './composer-external-sync'
|
||||
import { useComposerSourceState } from './composer-source-state'
|
||||
import { useComposerSubmitOrchestration } from './composer-submit-orchestration'
|
||||
|
||||
const originalState = useAppStore.getState()
|
||||
let originalApi: PropertyDescriptor | undefined
|
||||
const alpha = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
|
||||
const beta = { ...alpha, key: 'BET', name: 'Beta', siteId: 'site-b' }
|
||||
const localRepo: Repo = {
|
||||
id: 'local-repo',
|
||||
path: '/local-repo',
|
||||
displayName: 'Local project',
|
||||
badgeColor: '',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
}
|
||||
const executionRepo: Repo = {
|
||||
...localRepo,
|
||||
id: 'ssh-repo',
|
||||
path: '/ssh-repo',
|
||||
displayName: 'SSH project',
|
||||
connectionId: 'execution-b'
|
||||
}
|
||||
const issue: JiraIssue = {
|
||||
id: 'issue-a',
|
||||
key: 'ALP-1',
|
||||
siteId: alpha.siteId,
|
||||
title: 'Fix export',
|
||||
url: 'https://jira-a.example.invalid/browse/ALP-1',
|
||||
project: alpha,
|
||||
issueType: { id: 'task', name: 'Task' },
|
||||
status: {
|
||||
id: 'open',
|
||||
name: 'Open',
|
||||
categoryKey: 'new',
|
||||
categoryName: 'To do',
|
||||
colorName: 'blue-gray'
|
||||
},
|
||||
labels: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const status = vi.fn<Window['api']['jira']['status']>()
|
||||
const projects = vi.fn<Window['api']['jira']['listProjects']>()
|
||||
const search = vi.fn<Window['api']['jira']['searchIssues']>()
|
||||
const update = vi.fn<Window['api']['repos']['update']>()
|
||||
const create = vi.fn<Window['api']['worktrees']['create']>()
|
||||
const runtimeCall = vi.fn<Window['api']['runtimeEnvironments']['call']>()
|
||||
|
||||
function seedRepos(repos: readonly Repo[] = [localRepo, executionRepo], configured = true) {
|
||||
const projection = projectHostSetupProjectionFromRepos(repos)
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
repos,
|
||||
projects: [...projection.projects],
|
||||
projectHostSetups: [...projection.setups],
|
||||
projectGroups: [],
|
||||
newWorkspaceDraft: null,
|
||||
worktreesByRepo: {},
|
||||
sparsePresetsByRepo: {},
|
||||
workspaceHostScope: 'all',
|
||||
sshTargetLabels: configured ? new Map([['execution-b', 'Execution B']]) : new Map(),
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'execution-b',
|
||||
{
|
||||
targetId: 'execution-b',
|
||||
status: 'connected',
|
||||
connectionGeneration: 1,
|
||||
reconnectAttempt: 0,
|
||||
error: null
|
||||
}
|
||||
]
|
||||
]),
|
||||
detectedAgentIds: [],
|
||||
remoteDetectedAgentIds: { 'execution-b': [] },
|
||||
jiraSearchCache: {},
|
||||
jiraConnectionRevisions: {},
|
||||
jiraStatus: { connected: false, viewer: null },
|
||||
jiraStatusChecked: true,
|
||||
linearStatus: { connected: false, viewer: null },
|
||||
linearStatusChecked: true,
|
||||
pendingWorktreeCreations: {},
|
||||
activePendingCreationId: null,
|
||||
refreshPreflightStatus: vi.fn(async () => {}),
|
||||
ensureDetectedAgents: vi.fn(async () => []),
|
||||
ensureRemoteDetectedAgents: vi.fn(async () => [])
|
||||
})
|
||||
return projection
|
||||
}
|
||||
|
||||
function MappedRepositorySetting() {
|
||||
const repo = useAppStore((state) => state.repos.find((row) => row.id === executionRepo.id))
|
||||
if (!repo) {
|
||||
throw new Error('Expected execution repository')
|
||||
}
|
||||
return (
|
||||
<RepositoryJiraProjectSection
|
||||
repo={repo}
|
||||
updateRepo={(id, updates) =>
|
||||
useAppStore.getState().updateRepo(id, updates, { hostId: 'ssh:execution-b' })
|
||||
}
|
||||
forceVisible
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMappedComposer(configured = true) {
|
||||
const projection = seedRepos(useAppStore.getState().repos, configured)
|
||||
const mapped = useAppStore.getState().repos.find((repo) => repo.id === executionRepo.id)
|
||||
if (!mapped) {
|
||||
throw new Error('Expected mapped repository')
|
||||
}
|
||||
return renderHook(() => {
|
||||
const target = useComposerTargetState(
|
||||
{
|
||||
persistDraft: false,
|
||||
initialRepoId: mapped.id,
|
||||
initialWorkspaceTarget: getRepoWorkspaceCreationTargetHint(mapped, projection.setups)
|
||||
},
|
||||
decisions
|
||||
)
|
||||
const external = useComposerExternalSync(target)
|
||||
const source = useComposerSourceState(target, external)
|
||||
const submit = useComposerSubmitOrchestration(target, external, source)
|
||||
const lookup = useWorkItemSourceSearch({
|
||||
repos: target.composerTargetStore.repos,
|
||||
repoId: target.initialTargetState.repoId,
|
||||
selectedRepo: target.runtimeTargetSelection.selectedRepo ?? null,
|
||||
jiraSourceContext: target.sourceContextState.smartNameJiraSourceContext,
|
||||
value: 'export',
|
||||
branchesEnabled: false,
|
||||
repoBackedSourcesDisabled: true
|
||||
})
|
||||
return { target, source, submit, lookup }
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
|
||||
status.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: { accountId: 'account-a', displayName: 'Jira A', email: null },
|
||||
selectedSiteId: 'all',
|
||||
sites: [
|
||||
{
|
||||
id: alpha.siteId,
|
||||
siteUrl: 'https://jira-a.example.invalid',
|
||||
email: '',
|
||||
displayName: 'Jira A',
|
||||
accountId: 'account-a'
|
||||
}
|
||||
]
|
||||
})
|
||||
projects.mockResolvedValue([alpha, beta])
|
||||
search.mockResolvedValue([issue])
|
||||
update.mockImplementation(async ({ repoId, updates }) => {
|
||||
const repo = useAppStore.getState().repos.find((row) => row.id === repoId)
|
||||
if (!repo) {
|
||||
throw new Error('Unknown synthetic execution repository')
|
||||
}
|
||||
return { ...repo, defaultJiraProject: updates.defaultJiraProject }
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
jira: {
|
||||
status,
|
||||
readStatus: status,
|
||||
listProjects: projects,
|
||||
searchIssues: search,
|
||||
cancelSearchIssues: vi
|
||||
.fn<Window['api']['jira']['cancelSearchIssues']>()
|
||||
.mockResolvedValue(undefined)
|
||||
},
|
||||
repos: { update },
|
||||
worktrees: {
|
||||
create,
|
||||
listRetiredNames: vi
|
||||
.fn<Window['api']['worktrees']['listRetiredNames']>()
|
||||
.mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY)
|
||||
},
|
||||
runtimeEnvironments: { call: runtimeCall }
|
||||
}
|
||||
})
|
||||
seedRepos()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
useAppStore.setState(originalState, true)
|
||||
if (originalApi) {
|
||||
Object.defineProperty(window, 'api', originalApi)
|
||||
} else {
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
}
|
||||
})
|
||||
|
||||
describe('mapped Jira owner A to SSH execution owner B', () => {
|
||||
it('discovers A, verifies B save echo, queries A and starts B with the bound Jira link', async () => {
|
||||
render(<MappedRepositorySetting />)
|
||||
const picker = screen.getByRole('combobox', { name: 'Default Jira project' })
|
||||
await waitFor(() => expect(picker.hasAttribute('disabled')).toBe(false))
|
||||
expect(projects).toHaveBeenCalledWith({ siteId: 'all' })
|
||||
fireEvent.click(picker)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'ALP · Alpha' }))
|
||||
await waitFor(() => expect(picker.textContent).toContain('ALP · Alpha'))
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith({
|
||||
repoId: executionRepo.id,
|
||||
hostId: 'ssh:execution-b',
|
||||
updates: { defaultJiraProject: alpha }
|
||||
})
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(useAppStore.getState().repos[0].defaultJiraProject).toBeUndefined()
|
||||
|
||||
const { result } = renderMappedComposer()
|
||||
await waitFor(() => expect(result.current.lookup.jiraSourceConnected).toBe(true))
|
||||
expect(result.current.lookup.jiraConnectionStatus?.viewer?.accountId).toBe('account-a')
|
||||
expect(result.current.lookup.jiraSourceContext?.hostId).toBe('local')
|
||||
expect(result.current.target.runtimeTargetSelection.selectedRepoExecutionHostId).toBe(
|
||||
'ssh:execution-b'
|
||||
)
|
||||
act(() => result.current.lookup.setMode('jira'))
|
||||
await waitFor(() => expect(result.current.lookup.jiraIssues).toEqual([issue]))
|
||||
expect(search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jql: 'project = "10" AND (text ~ "export*")',
|
||||
siteId: alpha.siteId,
|
||||
limit: 12
|
||||
})
|
||||
)
|
||||
const row = result.current.lookup.rows.find((entry) => entry.kind === 'jira')
|
||||
if (!row || row.kind !== 'jira') {
|
||||
throw new Error('Expected discovered Jira row')
|
||||
}
|
||||
const linked = result.current.lookup.resolveRow(row)
|
||||
const boundSource = linked?.taskSourceContext
|
||||
if (!boundSource) {
|
||||
throw new Error('Expected account-bound Jira row')
|
||||
}
|
||||
act(() =>
|
||||
result.current.source.issueSourceActions.handleSmartJiraIssueSelect(row.issue, boundSource)
|
||||
)
|
||||
act(() => result.current.source.projectTargetActions.handleProjectChange('repo:local-repo'))
|
||||
expect(result.current.target.runtimeTargetSelection.selectedRepo).toBe(localRepo)
|
||||
act(() => result.current.source.projectTargetActions.handleProjectChange('repo:ssh-repo'))
|
||||
expect(result.current.target.runtimeTargetSelection.selectedRepo?.connectionId).toBe(
|
||||
'execution-b'
|
||||
)
|
||||
expect(result.current.target.sourceContextState.taskSourceContext).toMatchObject({
|
||||
hostId: 'local',
|
||||
providerIdentity: { provider: 'jira', siteId: alpha.siteId, projectKey: alpha.key }
|
||||
})
|
||||
const response = Promise.withResolvers<CreateWorktreeResult>()
|
||||
create.mockReturnValueOnce(response.promise)
|
||||
await act(async () => result.current.submit.quickSubmitAction.submitQuick(null))
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce())
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoId: executionRepo.id,
|
||||
setupDecision: 'skip',
|
||||
linkedWorkItem: expect.objectContaining({ provider: 'jira', jiraIdentifier: issue.key }),
|
||||
linkedTaskSourceContext: boundSource
|
||||
})
|
||||
)
|
||||
const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0]
|
||||
expect(pending.request.workspaceRunContext).toMatchObject({
|
||||
hostId: 'ssh:execution-b',
|
||||
repoId: executionRepo.id,
|
||||
path: executionRepo.path
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
// Cancel the presentation so this transport test does not seed or activate terminals.
|
||||
act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId))
|
||||
await act(async () =>
|
||||
response.resolve({ worktree: makeWorktree({ id: 'created-b', repoId: executionRepo.id }) })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().worktreesByRepo[executionRepo.id][0]).toMatchObject({
|
||||
id: 'created-b',
|
||||
hostId: 'ssh:execution-b'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an ignored save echo before exposing a default to the composer', async () => {
|
||||
update.mockResolvedValue(executionRepo)
|
||||
render(<MappedRepositorySetting />)
|
||||
const picker = screen.getByRole('combobox', { name: 'Default Jira project' })
|
||||
await waitFor(() => expect(picker.hasAttribute('disabled')).toBe(false))
|
||||
fireEvent.click(picker)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'ALP · Alpha' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain('Update this runtime')
|
||||
)
|
||||
const { result } = renderMappedComposer()
|
||||
expect(result.current.lookup.jiraProject).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores B mapping after target changes but refuses an unavailable execution host', async () => {
|
||||
seedRepos([localRepo, { ...executionRepo, defaultJiraProject: alpha }])
|
||||
const { result } = renderMappedComposer()
|
||||
expect(result.current.lookup.jiraProject).toEqual(alpha)
|
||||
act(() => result.current.source.projectTargetActions.handleProjectChange('repo:local-repo'))
|
||||
expect(result.current.lookup.jiraProject).toBeNull()
|
||||
act(() => result.current.source.projectTargetActions.handleProjectChange('repo:ssh-repo'))
|
||||
expect(result.current.lookup.jiraProject).toEqual(alpha)
|
||||
act(() => useAppStore.setState({ sshTargetLabels: new Map() }))
|
||||
expect(result.current.target.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
await act(async () => result.current.submit.quickSubmitAction.submitQuick(null))
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pendingWorktreeCreations).toEqual({})
|
||||
})
|
||||
|
||||
it('drops the first A inventory after account selection changes A → B → A', async () => {
|
||||
const firstA = Promise.withResolvers<JiraProject[]>()
|
||||
projects.mockReturnValueOnce(firstA.promise).mockResolvedValueOnce([beta])
|
||||
const accountA: RuntimeClientTarget = { kind: 'local' }
|
||||
const accountB: RuntimeClientTarget = { kind: 'environment', environmentId: 'jira-b' }
|
||||
runtimeCall.mockImplementation(async ({ method }) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
id: 'compatibility-b',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'jira-b' },
|
||||
result: {
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'jira.status') {
|
||||
return {
|
||||
id: 'status-b',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'jira-b' },
|
||||
result: { connected: true, viewer: null }
|
||||
}
|
||||
}
|
||||
if (method === 'jira.listProjects') {
|
||||
return { id: 'inventory-b', ok: true, _meta: { runtimeId: 'jira-b' }, result: [alpha] }
|
||||
}
|
||||
throw new Error(`Unexpected synthetic Jira B method: ${method}`)
|
||||
})
|
||||
const initialProps: { source: RuntimeClientTarget } = { source: accountA }
|
||||
const { result, rerender } = renderHook(({ source }) => useJiraProjectInventory(source, true), {
|
||||
initialProps
|
||||
})
|
||||
await waitFor(() => expect(projects).toHaveBeenCalledOnce())
|
||||
rerender({ source: accountB })
|
||||
await waitFor(() => expect(result.current.projects).toEqual([alpha]))
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'jira-b',
|
||||
method: 'jira.listProjects',
|
||||
params: { siteId: 'all' }
|
||||
})
|
||||
)
|
||||
rerender({ source: accountA })
|
||||
await waitFor(() => expect(result.current.projects).toEqual([beta]))
|
||||
await act(async () => firstA.resolve([alpha]))
|
||||
expect(result.current.projects).toEqual([beta])
|
||||
})
|
||||
|
||||
it('refuses an unresolved captured execution owner at the store transport boundary', async () => {
|
||||
const { result } = renderMappedComposer()
|
||||
const jiraSource = result.current.target.sourceContextState.smartNameJiraSourceContext
|
||||
if (!jiraSource) {
|
||||
throw new Error('Expected Jira source')
|
||||
}
|
||||
const createWorktree = useAppStore.getState().createWorktree
|
||||
const args: Parameters<typeof createWorktree> = [executionRepo.id, 'export']
|
||||
args[25] = {
|
||||
executionHostId: 'runtime:unresolved-owner',
|
||||
linkedTaskSourceContext: jiraSource
|
||||
}
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
await expect(createWorktree(...args)).rejects.toThrow('The workspace host is unresolved')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().worktreesByRepo).toEqual({})
|
||||
} finally {
|
||||
error.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -19320,7 +19320,6 @@
|
||||
"keywordJira": "jira",
|
||||
"keywordDefaultProject": "default project",
|
||||
"keywordIssuePicker": "issue picker"
|
||||
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
@@ -20112,7 +20111,7 @@
|
||||
"chooseTransition": "Choose a status change:",
|
||||
"cancel": "Cancel",
|
||||
"noTransition": "No status change to {{column}} is available for {{key}}.",
|
||||
"queryTooLong": "The Jira query is too long with Current sprint selected. Shorten the query or turn off Current sprint."
|
||||
"queryTooLong": "The Jira query is too long. Shorten the query or clear filters."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import { clearJiraImageCache } from './runtime-jira-image-cache'
|
||||
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
|
||||
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
|
||||
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
|
||||
import { jiraListProjectsForTarget, jiraStatusForTarget } from './runtime-jira-project-client'
|
||||
|
||||
import {
|
||||
jiraGetIssueForTarget,
|
||||
jiraUpdateIssueForTarget,
|
||||
@@ -52,6 +54,7 @@ export {
|
||||
jiraListAssignableUsersForProject,
|
||||
jiraSearchUsers
|
||||
} from './runtime-jira-user-fields-client'
|
||||
export { jiraStatusForTarget } from './runtime-jira-project-client'
|
||||
export type { RuntimeJiraSettings } from './runtime-jira-target'
|
||||
|
||||
export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string }
|
||||
@@ -62,16 +65,6 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraCon
|
||||
return jiraStatusForTarget(getJiraRuntimeTarget(settings))
|
||||
}
|
||||
|
||||
export async function jiraStatusForTarget(
|
||||
target: RuntimeClientTarget
|
||||
): Promise<JiraConnectionStatus> {
|
||||
return parseJiraConnectionStatus(
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<unknown>(target, 'jira.status', undefined, { timeoutMs: 15_000 })
|
||||
: await window.api.jira.status()
|
||||
)
|
||||
}
|
||||
|
||||
export async function jiraConnect(
|
||||
settings: RuntimeJiraSettings,
|
||||
args: { siteUrl: string; email: string; apiToken: string; authType?: JiraAuthType }
|
||||
@@ -231,12 +224,7 @@ export async function jiraListProjects(
|
||||
settings: RuntimeJiraSettings,
|
||||
siteId?: JiraSiteSelection | null
|
||||
): Promise<JiraProject[]> {
|
||||
const target = getJiraRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.jira.listProjects(siteId ? { siteId } : undefined)
|
||||
return jiraListProjectsForTarget(getJiraRuntimeTarget(settings), siteId)
|
||||
}
|
||||
|
||||
export async function jiraListIssueTypes(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { jiraListProjectsForTarget, jiraStatusForTarget } from './runtime-jira-project-client'
|
||||
|
||||
const { callRuntimeRpc, rowLessSourceTarget } = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
rowLessSourceTarget: vi.fn(() => {
|
||||
throw new Error('Project inventory must not resolve the default host')
|
||||
})
|
||||
}))
|
||||
vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc }))
|
||||
vi.mock('@/lib/default-creation-host', () => ({ rowLessSourceTarget }))
|
||||
|
||||
const status = vi.fn()
|
||||
const listProjects = vi.fn()
|
||||
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
status.mockResolvedValue({ connected: true, viewer: null })
|
||||
listProjects.mockResolvedValue([project])
|
||||
vi.stubGlobal('window', { api: { jira: { status, listProjects } } })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('Jira project inventory target transport', () => {
|
||||
it('reads local status and every site through IPC without a default-host lookup', async () => {
|
||||
await expect(jiraStatusForTarget({ kind: 'local' })).resolves.toMatchObject({ connected: true })
|
||||
await expect(jiraListProjectsForTarget({ kind: 'local' }, 'all')).resolves.toEqual([project])
|
||||
expect(status).toHaveBeenCalledExactlyOnceWith()
|
||||
expect(listProjects).toHaveBeenCalledExactlyOnceWith({ siteId: 'all' })
|
||||
expect(callRuntimeRpc).not.toHaveBeenCalled()
|
||||
expect(rowLessSourceTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends status and project reads to the explicit paired owner', async () => {
|
||||
const target = { kind: 'environment', environmentId: 'owner' } as const
|
||||
callRuntimeRpc.mockImplementation(async (_target, method) =>
|
||||
method === 'jira.status' ? { connected: true, viewer: null } : [project]
|
||||
)
|
||||
await expect(jiraStatusForTarget(target)).resolves.toMatchObject({ connected: true })
|
||||
await expect(jiraListProjectsForTarget(target, 'all')).resolves.toEqual([project])
|
||||
expect(callRuntimeRpc).toHaveBeenNthCalledWith(1, target, 'jira.status', undefined, {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
target,
|
||||
'jira.listProjects',
|
||||
{ siteId: 'all' },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
expect(status).not.toHaveBeenCalled()
|
||||
expect(listProjects).not.toHaveBeenCalled()
|
||||
expect(rowLessSourceTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the optional project selector absent and normalizes a malformed status', async () => {
|
||||
status.mockResolvedValue(undefined)
|
||||
await expect(jiraStatusForTarget({ kind: 'local' })).resolves.toEqual({
|
||||
connected: false,
|
||||
viewer: null
|
||||
})
|
||||
await jiraListProjectsForTarget({ kind: 'local' })
|
||||
expect(listProjects).toHaveBeenCalledExactlyOnceWith(undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
JiraConnectionStatus,
|
||||
JiraProject,
|
||||
JiraSiteSelection
|
||||
} from '../../../shared/jira-types'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { callRuntimeRpc } from './runtime-rpc-client'
|
||||
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
|
||||
|
||||
export async function jiraStatusForTarget(
|
||||
target: RuntimeClientTarget
|
||||
): Promise<JiraConnectionStatus> {
|
||||
return parseJiraConnectionStatus(
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<unknown>(target, 'jira.status', undefined, { timeoutMs: 15_000 })
|
||||
: await window.api.jira.status()
|
||||
)
|
||||
}
|
||||
|
||||
export async function jiraListProjectsForTarget(
|
||||
target: RuntimeClientTarget,
|
||||
siteId?: JiraSiteSelection | null
|
||||
): Promise<JiraProject[]> {
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.jira.listProjects(siteId ? { siteId } : undefined)
|
||||
}
|
||||
Reference in New Issue
Block a user