Fix Jira inventory ownership and composed query limits

This commit is contained in:
Neil
2026-10-14 06:03:02 -07:00
parent af29307fef
commit 9b174ae4aa
18 changed files with 1419 additions and 79 deletions
+1
View File
@@ -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()
}
})
})
+1 -2
View File
@@ -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)
}