Filter Jira preset views by multiple workflow statuses (#28623)

Combine remembered Jira status filters with presets and saved JQL on the connection host, protecting obsolete owner actions and keeping incompatible controls hidden in linked mode.

Co-authored-by: Brennan Benson <brennankbenson@gmail.com>
This commit is contained in:
Neil
2026-10-14 03:47:08 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 4731172bd8
commit 702777e2fe
49 changed files with 1268 additions and 44 deletions
+15 -2
View File
@@ -1,3 +1,4 @@
import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params'
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
import { _resetPreflightCache } from './preflight'
@@ -23,6 +24,7 @@ import type {
JiraConnectArgs,
JiraCreateIssueArgs,
JiraIssueFilter,
JiraIssueListFilters,
JiraIssueUpdate,
JiraSiteSelection
} from '../../shared/jira-types'
@@ -154,12 +156,23 @@ export function registerJiraHandlers(): void {
'jira:listIssues',
async (
_event,
args?: { filter?: JiraIssueFilter; limit?: number; siteId?: JiraSiteSelection }
args?: {
filter?: JiraIssueFilter
limit?: number
siteId?: JiraSiteSelection
filters?: JiraIssueListFilters
}
) => {
const filter = VALID_FILTERS.has(args?.filter as JiraIssueFilter)
? (args!.filter as JiraIssueFilter)
: undefined
return listIssues(filter, clampLimit(args?.limit), normalizeSiteSelection(args?.siteId))
return listIssues(
filter,
clampLimit(args?.limit),
normalizeSiteSelection(args?.siteId),
false,
args?.filters ? JiraIssueListFiltersParam.parse(args.filters) : undefined
)
}
)
+10
View File
@@ -1,3 +1,5 @@
import { normalizeJiraQuickFilterState } from '../../shared/jira-quick-filters'
import { JiraQuickFilterStateParam } from '../../shared/rpc-contract/jira-quick-filter-params'
import {
normalizeJiraSavedFilterState,
mutateJiraSavedFilterState
@@ -64,6 +66,14 @@ export function registerUIHandlers(
}
})
ipcMain.handle('ui:getJiraQuickFilters', () =>
normalizeJiraQuickFilterState(store.getUI().jiraQuickFilterState)
)
ipcMain.handle('ui:setJiraQuickFilters', (_event, input: unknown) => {
const state = normalizeJiraQuickFilterState(JiraQuickFilterStateParam.parse(input))
store.updateUI({ jiraQuickFilterState: state })
return state
})
ipcMain.handle('ui:getJiraSavedFilters', () =>
normalizeJiraSavedFilterState(store.getUI().jiraSavedFilterState)
)
+11 -5
View File
@@ -1,5 +1,10 @@
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types'
import type {
JiraIssue,
JiraIssueFilter,
JiraIssueListFilters,
JiraSiteSelection
} from '../../shared/jira-types'
import { buildJiraPresetJql } from '../../shared/jira-preset-jql'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
@@ -18,7 +23,7 @@ import {
const ISSUE_SEARCH_TIMEOUT_MS = 30_000
const PROJECTS_PER_QUERY = 100
type JiraIssueQuery = { filter: JiraIssueFilter } | { jql: string }
type JiraIssueQuery = { filter: JiraIssueFilter; filters?: JiraIssueListFilters } | { jql: string }
function clampLimit(limit: number | undefined, fallback = 30): number {
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
@@ -38,7 +43,7 @@ async function readIssuesForClient(
if ('jql' in query) {
return searchIssuesForClient(entry, query.jql, limit, agileFields, signal)
}
const jql = buildJiraPresetJql(query.filter)
const jql = buildJiraPresetJql(query.filter, query.filters)
if (query.filter !== 'all' || entry.site.authType === 'server') {
return searchIssuesForClient(entry, jql, limit, agileFields, signal)
}
@@ -122,9 +127,10 @@ export async function listIssues(
filter: JiraIssueFilter = 'assigned',
limit = 30,
siteId?: JiraSiteSelection | null,
strict = false
strict = false,
filters?: JiraIssueListFilters
): Promise<JiraIssue[]> {
return runIssueSearch({ filter }, limit, siteId, undefined, strict)
return runIssueSearch({ filter, filters }, limit, siteId, undefined, strict)
}
export async function searchIssues(
+17
View File
@@ -121,6 +121,23 @@ describe('Jira preset filters', () => {
expect(jiraRequest).toHaveBeenCalledTimes(3)
})
it('composes multi-status filters inside the bounded Cloud All query', async () => {
jiraRequest.mockImplementation(async (_entry, path, init) => {
if (path.includes('/project/search')) {
return { isLast: true, values: [{ id: '10', key: 'APP' }] }
}
expect(requestJql(init)).toBe(
'project in ("10") AND statusCategory != Done AND status in ("Blocked", "In Progress") ORDER BY updated DESC'
)
return { issues: [issue('APP-1', false, false)] }
})
const { listIssues } = await import('./jira-issue-search')
await expect(
listIssues('all', 20, 'cloud', true, { statuses: ['Blocked', 'In Progress'] })
).resolves.toMatchObject([{ key: 'APP-1' }])
expect(jiraRequest).toHaveBeenCalledTimes(2)
})
it('keeps Server/DC All on classic search without project discovery', async () => {
getClients.mockReturnValue([site('server', 'server')])
jiraRequest.mockResolvedValue({ issues: [] })
@@ -22,9 +22,13 @@ it('persists multiple saved filters and the active selection across a profile re
activeFilterId: 'bugs'
}
const store = createStore()
store.updateUI({ jiraSavedFilterState: state })
store.updateUI({
jiraSavedFilterState: state,
jiraQuickFilterState: { statuses: ['In Progress', 'Blocked'] }
})
store.flush()
await closeTestStores()
const reloaded = createStore()
expect(normalizeJiraSavedFilterState(reloaded.getUI().jiraSavedFilterState)).toEqual(state)
expect(reloaded.getUI().jiraQuickFilterState).toEqual({ statuses: ['In Progress', 'Blocked'] })
})
+19
View File
@@ -1,3 +1,5 @@
import { normalizeJiraQuickFilterState } from '../../../../shared/jira-quick-filters'
import { JiraQuickFilterStateParam } from '../../../../shared/rpc-contract/jira-quick-filter-params'
import {
normalizeJiraSavedFilterState,
mutateJiraSavedFilterState
@@ -19,6 +21,23 @@ import { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/ter
import { AgentLaunchSettingsMutation } from '../../../../shared/rpc-contract/agent-launch-settings-params'
export const CLIENT_UI_METHODS = [
defineMethod({
name: 'ui.getJiraQuickFilters',
permission: 'workspace',
params: null,
handler: (_params, { runtime }) =>
normalizeJiraQuickFilterState(runtime.getUIState().jiraQuickFilterState)
}),
defineMethod({
name: 'ui.setJiraQuickFilters',
permission: 'workspace',
params: JiraQuickFilterStateParam,
handler: (params, { runtime }) => {
const state = normalizeJiraQuickFilterState(params)
runtime.updateUIState({ jiraQuickFilterState: state })
return state
}
}),
defineMethod({
name: 'ui.getJiraSavedFilters',
permission: 'workspace',
@@ -83,3 +83,27 @@ it('routes mobile saved-filter persistence to the execution host', () => {
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraSavedFilters')).toBe('execution-host')
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.mutateJiraSavedFilters')).toBe('execution-host')
})
it('persists quick filters on the execution host without rewriting saved JQL or resume fields', async () => {
const { dispatch, read, updateUIState } = setup()
await dispatch('ui.mutateJiraSavedFilters', { kind: 'save', filter })
expect(
await dispatch('ui.setJiraQuickFilters', { statuses: ['In Progress', 'Blocked'] })
).toMatchObject({
ok: true,
result: { statuses: ['In Progress', 'Blocked'] }
})
expect(await dispatch('ui.getJiraQuickFilters')).toMatchObject({
ok: true,
result: { statuses: ['In Progress', 'Blocked'] }
})
expect(read().jiraSavedFilterState?.filters).toEqual([filter])
expect(read().taskResumeState).toEqual({ jiraQuery: 'project = OLD' })
expect(updateUIState.mock.calls.every(([update]) => !('taskResumeState' in update))).toBe(true)
expect(await dispatch('ui.setJiraQuickFilters', { statuses: ['x'.repeat(129)] })).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraQuickFilters')).toBe('execution-host')
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.setJiraQuickFilters')).toBe('execution-host')
})
+29 -1
View File
@@ -109,7 +109,13 @@ describe('jira RPC methods', () => {
undefined,
undefined
)
expect(runtime.jiraListIssues).toHaveBeenCalledWith('assigned', 20, 'site-1', undefined)
expect(runtime.jiraListIssues).toHaveBeenCalledWith(
'assigned',
20,
'site-1',
undefined,
undefined
)
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith(
'ABC-3',
@@ -281,6 +287,28 @@ describe('jira RPC methods', () => {
})
})
it('forwards strict reads and optional status filters while accepting older requests', async () => {
const jiraListIssues = vi.fn().mockResolvedValue([])
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the runtime identity and list method are invoked by these requests.
const runtime = { getRuntimeId: () => 'host-a', jiraListIssues } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS })
expect(
await dispatcher.dispatch(
makeRequest('jira.listIssues', {
filter: 'all',
siteId: 'site-1',
strict: true,
filters: { statuses: ['In Progress'] }
})
)
).toMatchObject({ ok: true })
expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, 'site-1', true, {
statuses: ['In Progress']
})
await dispatcher.dispatch(makeRequest('jira.listIssues', { filter: 'all' }))
expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, undefined, undefined, undefined)
})
describe('Jira title generation RPC', () => {
it('binds generation and stop to the authenticated device across one-shot sockets', async () => {
const generate = vi.fn().mockResolvedValue({ success: true, summary: 'Title' })
+7 -1
View File
@@ -119,7 +119,13 @@ export const JIRA_METHODS = [
permission: 'workspace',
params: ListIssues,
handler: async (params, { runtime }) =>
runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId, params?.strict)
runtime.jiraListIssues(
params?.filter,
params?.limit,
params?.siteId,
params?.strict,
params?.filters
)
}),
defineMethod({
name: 'jira.getIssue',
@@ -10,6 +10,7 @@ import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-sche
// forgotten, which is what the parity assertion below enforces.
type MainOwnedUIState =
| 'jiraSavedFilterState'
| 'jiraQuickFilterState'
| '_explorerDisplayRootMigrated'
| 'trayMinimizeNoticeShown'
| 'dashboardPopoutBounds'
+4 -2
View File
@@ -11,6 +11,7 @@ import type {
JiraConnectArgs,
JiraCreateIssueArgs,
JiraIssueFilter,
JiraIssueListFilters,
JiraIssueUpdate,
JiraSiteSelection
} from '../../shared/jira-types'
@@ -92,9 +93,10 @@ export class RuntimeJiraCommands {
filter?: JiraIssueFilter,
limit = 30,
siteId?: JiraSiteSelection,
strict = false
strict = false,
filters?: JiraIssueListFilters
): ReturnType<typeof listIssues> {
return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict)
return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict, filters)
}
jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType<typeof createIssue> {
@@ -6,6 +6,8 @@ import { PAIRED_DESKTOP_METHODS } from './runtime-rpc-mobile-paired-desktop-meth
export type MobileRpcMethodRoute = 'execution-host' | 'paired-desktop'
export const EXECUTION_HOST_METHODS = [
'ui.getJiraQuickFilters',
'ui.setJiraQuickFilters',
'ui.getJiraSavedFilters',
'ui.mutateJiraSavedFilters',
'agent.launch',
+2
View File
@@ -10,6 +10,7 @@ import type {
JiraCreateIssueArgs,
JiraIssue,
JiraIssueFilter,
JiraIssueListFilters,
JiraIssueType,
JiraIssueUpdate,
JiraPriority,
@@ -46,6 +47,7 @@ export type JiraApi = {
filter?: JiraIssueFilter
limit?: number
siteId?: JiraSiteSelection
filters?: JiraIssueListFilters
}) => Promise<JiraIssue[]>
getIssue: (args: { key: string; siteId?: string }) => Promise<JiraIssue | null>
lookupIssueSummary: (args: {
@@ -5,6 +5,8 @@ import type { KeybindingActionId } from '../../shared/keybindings'
import type { PreloadApi } from '../api-types'
export const uiStateAndMenuCommandsApi = {
getJiraQuickFilters: () => ipcRenderer.invoke('ui:getJiraQuickFilters'),
setJiraQuickFilters: (state) => ipcRenderer.invoke('ui:setJiraQuickFilters', state),
getJiraSavedFilters: () => ipcRenderer.invoke('ui:getJiraSavedFilters'),
mutateJiraSavedFilters: (mutation) => ipcRenderer.invoke('ui:mutateJiraSavedFilters', mutation),
get: () => ipcRenderer.invoke('ui:get'),
+3
View File
@@ -1,4 +1,5 @@
import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts'
import type { JiraQuickFilterState } from '../../shared/jira-quick-filters'
import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared/jira-saved-filters'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
@@ -51,6 +52,8 @@ import type {
export type CloseActiveTabPayload = { sourceId: string }
export type UiCommandEventApi = {
getJiraQuickFilters?: () => Promise<JiraQuickFilterState>
setJiraQuickFilters?: (state: JiraQuickFilterState) => Promise<JiraQuickFilterState>
getJiraSavedFilters?: () => Promise<JiraSavedFilterState>
mutateJiraSavedFilters?: (mutation: JiraSavedFilterMutation) => Promise<JiraSavedFilterState>
get: () => Promise<PersistedUIState>
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
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 {
jiraHasWorkspace: false,
selectedJiraStatuses: ['Developed'],
selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
jiraQuickFiltersReady: true,
jiraQuickFiltersPending: false,
jiraQuickFiltersLoading: false,
jiraQuickFiltersError: null,
retryJiraQuickFilters: vi.fn(),
jiraStatusCatalog: null,
jiraTaskSourceScopeKey: 'jira-owner',
selectedJiraSiteId: 'site',
activeJiraPreset: 'assigned',
jiraSearchInput: '',
setJiraSearchInput: vi.fn(),
setAppliedJiraSearch: vi.fn(),
clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined),
jiraSavedFiltersPending: false
} as unknown as TaskPageComposerActionsModel
}
it('keeps a selected status and reachable Clear when the loaded catalog is empty', () => {
const state = model()
render(<TaskPageJiraStatusFilters model={state} />)
expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
'true'
)
fireEvent.click(screen.getByRole('button', { name: 'Clear statuses' }))
expect(state.selectJiraStatuses).toHaveBeenCalledWith([])
})
it('multi-selects actual statuses and hands the composed query to manual editing', async () => {
const state = model()
state.selectedJiraStatuses = ['In Progress']
state.jiraStatusCatalog = {
scopeKey: 'jira-owner::site::assigned',
items: [
{ name: 'Blocked', categoryKey: 'indeterminate' },
{ name: 'In Progress', categoryKey: 'indeterminate' }
]
}
render(<TaskPageJiraStatusFilters model={state} />)
fireEvent.click(screen.getByRole('button', { name: 'Blocked' }))
expect(state.selectJiraStatuses).toHaveBeenCalledWith(['In Progress', 'Blocked'])
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
await waitFor(() =>
expect(state.setJiraSearchInput).toHaveBeenCalledWith(
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
)
)
expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
)
})
it('hides status controls and preserves an existing manual query', () => {
const state = model()
state.jiraSearchInput = 'project = CUSTOM ORDER BY created ASC'
render(<TaskPageJiraStatusFilters model={state} />)
expect(screen.queryByRole('button', { name: 'Developed' })).toBeNull()
expect(screen.getByText(/Status filters apply to preset views/)).toBeDefined()
expect(state.setJiraSearchInput).not.toHaveBeenCalled()
})
it('hides statuses and query preview in linked mode without clearing selection', () => {
const state = model()
state.jiraHasWorkspace = true
const { container } = render(<TaskPageJiraStatusFilters model={state} />)
expect(container.innerHTML).toBe('')
expect(state.selectJiraStatuses).not.toHaveBeenCalled()
expect(state.selectedJiraStatuses).toEqual(['Developed'])
})
@@ -0,0 +1,141 @@
import { Button } from '@/components/ui/button'
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 type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
export function TaskPageJiraStatusFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
jiraHasWorkspace,
selectedJiraStatuses,
selectJiraStatuses,
jiraQuickFiltersReady,
jiraQuickFiltersPending,
jiraQuickFiltersLoading,
jiraQuickFiltersError,
retryJiraQuickFilters,
jiraStatusCatalog,
jiraTaskSourceScopeKey,
selectedJiraSiteId,
activeJiraPreset,
jiraSearchInput,
setJiraSearchInput,
setAppliedJiraSearch,
clearJiraSavedFilterSelection,
jiraSavedFiltersPending
} = model
if (jiraHasWorkspace) {
return null
}
const scopeKey = `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`
const statuses = includeSelectedJiraStatuses(
jiraStatusCatalog?.scopeKey === scopeKey ? jiraStatusCatalog.items : [],
selectedJiraStatuses
)
const preview = buildJiraPresetJql(activeJiraPreset, { statuses: selectedJiraStatuses })
const disabled =
!jiraQuickFiltersReady ||
jiraQuickFiltersLoading ||
jiraQuickFiltersPending ||
jiraSavedFiltersPending
if (jiraSearchInput.trim()) {
return selectedJiraStatuses.length ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'jira.statusFilters.manualQuery',
'Status filters apply to preset views. Clear the search to use them.'
)}
</p>
) : null
}
return (
<div className="mt-3 space-y-2">
{jiraQuickFiltersError ? (
<div role="alert" className="flex items-center gap-2 text-xs text-destructive">
<span>{jiraQuickFiltersError}</span>
<Button
variant="ghost"
size="xs"
disabled={jiraQuickFiltersLoading || jiraQuickFiltersPending}
onClick={retryJiraQuickFilters}
>
{translate('jira.savedFilters.retry', 'Retry')}
</Button>
</div>
) : null}
{statuses.length ? (
<div className="flex flex-wrap items-center gap-2">
<ToggleGroup
type="multiple"
variant="segmented"
size="xs"
spacing={1}
value={selectedJiraStatuses}
disabled={disabled}
aria-label={translate('jira.statusFilters.label', 'Filter Jira issues by status')}
onValueChange={(next) => {
void selectJiraStatuses(next).catch(() => {})
}}
className="flex-wrap"
>
{statuses.map((status) => (
<ToggleGroupItem key={status.name} value={status.name}>
{status.name}
</ToggleGroupItem>
))}
</ToggleGroup>
{selectedJiraStatuses.length ? (
<Button
variant="ghost"
size="xs"
disabled={disabled}
onClick={() => {
void selectJiraStatuses([]).catch(() => {})
}}
>
{translate('jira.statusFilters.clear', 'Clear statuses')}
</Button>
) : null}
</div>
) : null}
{selectedJiraStatuses.length ? (
<>
<div className="flex items-center gap-2">
<Input
readOnly
value={preview}
aria-label={translate('jira.statusFilters.jql', 'Status filter JQL')}
/>
<Button
variant="outline"
size="sm"
disabled={disabled}
onClick={() => {
void clearJiraSavedFilterSelection()
.then(() => {
setJiraSearchInput(preview)
setAppliedJiraSearch(preview)
})
.catch(() => {})
}}
>
{translate('jira.statusFilters.editJql', 'Edit as JQL')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{translate(
'jira.statusFilters.catalogHint',
'Statuses come from loaded issues. Clear statuses to discover others in this view.'
)}
</p>
</>
) : null}
</div>
)
}
@@ -0,0 +1,24 @@
import { expect, it } from 'vitest'
import {
includeSelectedJiraStatuses,
orderJiraStatusCatalog
} from './task-page-jira-status-catalog'
it('orders actual workflow statuses by category and name', () => {
expect(
orderJiraStatusCatalog([
{ name: 'Done', categoryKey: 'done' },
{ name: 'Ready', categoryKey: 'indeterminate' },
{ name: 'Open', categoryKey: 'new' },
{ name: 'Blocked', categoryKey: 'indeterminate' }
]).map((status) => status.name)
).toEqual(['Open', 'Blocked', 'Ready', 'Done'])
})
it('keeps selected statuses visible even when a new scope has not loaded them', () => {
expect(
includeSelectedJiraStatuses([{ name: 'Open', categoryKey: 'new' }], ['Open', 'Developed'])
).toEqual([
{ name: 'Open', categoryKey: 'new' },
{ name: 'Developed', categoryKey: 'other' }
])
})
@@ -0,0 +1,44 @@
import type { JiraIssue } from '../../../shared/jira-types'
export type JiraStatusCatalogItem = { name: string; categoryKey: string }
const STATUS_NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' })
const STATUS_NAME_TIE_BREAKER = new Intl.Collator('en')
const STATUS_CATEGORY_ORDER = new Map([
['new', 0],
['indeterminate', 1],
['done', 2]
])
export function buildJiraStatusCatalog(issues: readonly JiraIssue[]): JiraStatusCatalogItem[] {
const statuses = new Map<string, JiraStatusCatalogItem>()
for (const { status } of issues) {
if (!statuses.has(status.name)) {
statuses.set(status.name, { name: status.name, categoryKey: status.categoryKey })
}
}
return orderJiraStatusCatalog([...statuses.values()])
}
export function orderJiraStatusCatalog(items: JiraStatusCatalogItem[]): JiraStatusCatalogItem[] {
return [...items].sort((left, right) => {
const category =
(STATUS_CATEGORY_ORDER.get(left.categoryKey) ?? 3) -
(STATUS_CATEGORY_ORDER.get(right.categoryKey) ?? 3)
return (
category ||
STATUS_NAME_COLLATOR.compare(left.name, right.name) ||
STATUS_NAME_TIE_BREAKER.compare(left.name, right.name)
)
})
}
export function includeSelectedJiraStatuses(
catalog: JiraStatusCatalogItem[],
selected: string[]
): JiraStatusCatalogItem[] {
const known = new Set(catalog.map((status) => status.name))
return orderJiraStatusCatalog([
...catalog,
...selected.filter((name) => !known.has(name)).map((name) => ({ name, categoryKey: 'other' }))
])
}
@@ -1,3 +1,4 @@
import { TaskPageJiraStatusFilters } from '../../TaskPageJiraStatusFilters'
import { TaskPageJiraSavedFilters } from '../../TaskPageJiraSavedFilters'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { cn } from '@/lib/utils'
@@ -177,6 +178,7 @@ export function TaskPageJiraFilters({
{jiraSavedFiltersError}
</p>
) : null}
<TaskPageJiraStatusFilters model={model} />
<div className="mt-3 flex items-center gap-3">
<div className="relative min-w-[320px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -40,6 +40,9 @@ function createModel(): TaskPageLinearCollectionEffectsModel {
jiraActiveSavedFilterId: null,
jiraSavedFilters: [],
jiraSavedFiltersLoading: false,
selectedJiraStatuses: [],
jiraQuickFiltersLoading: false,
setJiraStatusCatalog: vi.fn(),
setJiraProjectStatusOrder: vi.fn(),
displayedJiraIssues: []
}
@@ -140,6 +143,40 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
})
})
it('uses the preset list path for status filters and restores it after clearing', async () => {
const model = createModel()
model.selectedJiraStatuses = ['In Progress', 'Blocked']
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1))
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
sourceContext: null,
force: false,
filters: { statuses: ['In Progress', 'Blocked'] }
})
expect(searchJiraIssues).not.toHaveBeenCalled()
await act(async () => {
model.selectedJiraStatuses = []
rerender()
})
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
sourceContext: null,
force: false
})
})
it('keeps manual JQL independent of a persisted status selection', async () => {
const model = createModel()
model.selectedJiraStatuses = ['Blocked']
model.appliedJiraSearch = 'project = APP ORDER BY created ASC'
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
expect(searchJiraIssues).toHaveBeenCalledWith('project = APP ORDER BY created ASC', 50, {
sourceContext: null,
force: false
})
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('passes force through the text-search wrapper on refresh', async () => {
const model = createModel()
model.appliedJiraSearch = 'fix login'
@@ -1,3 +1,4 @@
import { buildJiraStatusCatalog } from './task-page-jira-status-catalog'
import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects'
import { useEffect, useRef } from 'react'
import {
@@ -43,6 +44,9 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
jiraActiveSavedFilterId,
jiraSavedFilters,
jiraSavedFiltersLoading,
selectedJiraStatuses,
jiraQuickFiltersLoading,
setJiraStatusCatalog,
setJiraProjectStatusOrder,
displayedJiraIssues
} = model
@@ -76,7 +80,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
if (taskSource !== 'jira') {
return
}
if (!jiraConnected || jiraSavedFiltersLoading) {
if (!jiraConnected || jiraSavedFiltersLoading || jiraQuickFiltersLoading) {
return
}
const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce
@@ -103,7 +107,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
).then((result) => ({ ...result, error: null }))
: listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, {
sourceContext: jiraTaskSourceContext,
force
force,
...(selectedJiraStatuses.length ? { filters: { statuses: selectedJiraStatuses } } : {})
}).then((issues) => ({ issues, jqlRejection: null, error: null }))
void request
.then((result) => {
@@ -112,6 +117,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
return
}
setJiraError(result.error)
if (!jiraHasWorkspace && !trimmed && selectedJiraStatuses.length === 0) {
setJiraStatusCatalog({
scopeKey: `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`,
items: buildJiraStatusCatalog(issues)
})
}
setJiraIssues(issues)
setJiraJqlRejection(jqlRejection)
setJiraLoading(false)
@@ -161,6 +172,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
jiraActiveSavedFilterId,
jiraSavedFilters,
jiraSavedFiltersLoading,
selectedJiraStatuses,
jiraQuickFiltersLoading,
taskResumeApplied,
jiraTaskSourceContext,
jiraTaskSourceScopeKey
@@ -1,3 +1,5 @@
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
import { useState, useMemo, useEffect, useCallback } from 'react'
@@ -58,6 +60,11 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
setAppliedJiraSearch(jql)
}, [])
const savedFilters = useTaskPageJiraSavedFilters(model, setSavedFilterQuery)
const quickFilters = useTaskPageJiraQuickFilters(model)
const [jiraStatusCatalog, setJiraStatusCatalog] = useState<{
scopeKey: string
items: JiraStatusCatalogItem[]
} | null>(null)
const [activeJiraPreset, setActiveJiraPreset] = useState<JiraPresetId>('assigned')
const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0)
const [jiraProjectStatusOrder, setJiraProjectStatusOrder] = useState<{
@@ -157,6 +164,9 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
jiraHasWorkspace,
setJiraHasWorkspace,
...savedFilters,
...quickFilters,
jiraStatusCatalog,
setJiraStatusCatalog,
jiraIssues,
setJiraIssues,
jiraLoading,
@@ -0,0 +1,220 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraQuickFilterState } from '../../../shared/jira-quick-filters'
import { UNRESOLVED_OWNER_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host'
const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
vi.mock('@/runtime/runtime-jira-quick-filter-state', () => ({
readJiraQuickFilterState: read,
writeJiraQuickFilterState: write
}))
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(cleanup)
function model(
host: string | null,
accountHost = 'account-owner'
): Parameters<typeof useTaskPageJiraQuickFilters>[0] {
return {
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
taskSource: 'jira',
taskResumeApplied: true,
jiraTaskSourceContext: host
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: toRuntimeExecutionHostId(host)
}
: null
}
}
it('uses the captured account owner without a source context and keeps source ownership first', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
write.mockResolvedValue({ statuses: ['Open'] })
const { result, rerender } = renderHook(
({ sourceHost }) => useTaskPageJiraQuickFilters(model(sourceHost)),
{ initialProps: { sourceHost: '' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'account-owner' })
await act(async () => result.current.selectJiraStatuses(['Open']))
expect(write).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'account-owner' },
{ statuses: ['Open'] }
)
rerender({ sourceHost: 'source-owner' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'source-owner' })
})
it('does not load or write status filters for an unresolved owner', async () => {
const { result } = renderHook(() =>
useTaskPageJiraQuickFilters({
...model(null),
accountBackedTaskSourceHostId: UNRESOLVED_OWNER_HOST_ID
})
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => result.current.selectJiraStatuses(['Open']))
expect(result.current.jiraQuickFiltersReady).toBe(false)
expect(read).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
})
it('restores selection after remount and waits for writes to reach the host', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
.mockResolvedValueOnce({ statuses: ['In Progress'] })
let finish: (value: JiraQuickFilterState) => void = () => {}
write.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const first = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(first.result.current.jiraQuickFiltersLoading).toBe(false))
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = first.result.current.selectJiraStatuses(['In Progress'])
})
expect(first.result.current.selectedJiraStatuses).toEqual(['Blocked'])
expect(first.result.current.jiraQuickFiltersPending).toBe(true)
await act(async () => {
finish({ statuses: ['In Progress'] })
await pending
})
expect(first.result.current.selectedJiraStatuses).toEqual(['In Progress'])
first.unmount()
const restored = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(restored.result.current.selectedJiraStatuses).toEqual(['In Progress']))
})
it('retains selection after a failed write', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
write.mockRejectedValue(new Error('Host unreachable'))
const { result } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => {
await expect(result.current.selectJiraStatuses([])).rejects.toThrow('Host unreachable')
})
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
expect(result.current.jiraQuickFiltersError).toBe('Host unreachable')
})
it('ignores a late mutation reply after changing Jira execution hosts', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
.mockResolvedValueOnce({ statuses: ['Open'] })
let finish: (value: JiraQuickFilterState) => void = () => {}
write.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
initialProps: { host: 'a' }
})
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = result.current.selectJiraStatuses(['In Progress'])
})
rerender({ host: 'b' })
await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open']))
await act(async () => {
finish({ statuses: ['In Progress'] })
await pending
})
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
})
it('keeps unsupported-host empty selection stable across renders', async () => {
read.mockRejectedValue(new Error('Update host'))
const { result, rerender } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const first = result.current.selectedJiraStatuses
rerender()
expect(result.current.selectedJiraStatuses).toBe(first)
expect(result.current.jiraQuickFiltersReady).toBe(false)
})
it('does not send a status filter action retained from a previous account owner', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
const { result, rerender } = renderHook(
({ accountHost }) => useTaskPageJiraQuickFilters(model(null, accountHost)),
{ initialProps: { accountHost: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const oldAction = result.current.selectJiraStatuses
rerender({ accountHost: 'host-b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => oldAction(['Open']))
expect(write).not.toHaveBeenCalled()
})
it('rejects a retained status action after returning to its earlier owner', async () => {
read.mockResolvedValue({ statuses: ['Open'] })
write.mockResolvedValue({ statuses: ['Closed'] })
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
initialProps: { host: 'a' }
})
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const oldAction = result.current.selectJiraStatuses
rerender({ host: 'b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
rerender({ host: 'a' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => oldAction(['Closed']))
expect(write).not.toHaveBeenCalled()
})
it.each(['success', 'failure'] as const)(
'keeps a new owner visit pending after an old %s settlement',
async (outcome) => {
read.mockResolvedValue({ statuses: ['Open'] })
let finishOld: (state: JiraQuickFilterState) => void = () => {}
let failOld: (reason: Error) => void = () => {}
let finishNew: (state: JiraQuickFilterState) => void = () => {}
write
.mockReturnValueOnce(
new Promise((resolve, reject) => {
finishOld = resolve
failOld = reject
})
)
.mockReturnValueOnce(
new Promise((resolve) => {
finishNew = resolve
})
)
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraQuickFilters(model(host)),
{ initialProps: { host: 'a' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let oldWrite = Promise.resolve()
act(() => {
oldWrite = result.current.selectJiraStatuses(['Closed']).catch(() => {})
})
rerender({ host: 'b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
rerender({ host: 'a' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let newWrite = Promise.resolve()
act(() => {
newWrite = result.current.selectJiraStatuses(['In Progress'])
})
await act(async () => {
if (outcome === 'success') {
finishOld({ statuses: ['Closed'] })
} else {
failOld(new Error('Old request failed'))
}
await oldWrite
})
expect(result.current.jiraQuickFiltersPending).toBe(true)
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
expect(result.current.jiraQuickFiltersError).toBeNull()
await act(async () => {
finishNew({ statuses: ['In Progress'] })
await newWrite
})
expect(result.current.selectedJiraStatuses).toEqual(['In Progress'])
expect(result.current.jiraQuickFiltersPending).toBe(false)
}
)
@@ -0,0 +1,123 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
import {
readJiraQuickFilterState,
writeJiraQuickFilterState
} from '@/runtime/runtime-jira-quick-filter-state'
import {
normalizeJiraQuickFilterState,
type JiraQuickFilterState
} from '../../../shared/jira-quick-filters'
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
import { translate } from '@/i18n/i18n'
const EMPTY_QUICK_FILTER_STATE: JiraQuickFilterState = { statuses: [] }
type JiraQuickFilterModel = Pick<
TaskPageLinearViewStateModel,
'jiraTaskSourceContext' | 'accountBackedTaskSourceHostId' | 'taskResumeApplied' | 'taskSource'
>
export function useTaskPageJiraQuickFilters(model: JiraQuickFilterModel) {
const ownerKey = model.jiraTaskSourceContext?.hostId ?? model.accountBackedTaskSourceHostId
const target = useMemo(() => ipcOrRuntimeTargetForHostId(ownerKey), [ownerKey])
const [snapshot, setSnapshot] = useState({
ownerKey: '',
state: normalizeJiraQuickFilterState(undefined)
})
const [loading, setLoading] = useState(true)
const lifetime = useMemo(() => ({ ownerKey }), [ownerKey])
const [pendingLifetime, setPendingLifetime] = useState<typeof lifetime | null>(null)
const [error, setError] = useState<string | null>(null)
const [refresh, setRefresh] = useState(0)
const lifetimeRef = useRef<typeof lifetime | null>(null)
const writePendingRef = useRef<typeof lifetime | null>(null)
useLayoutEffect(() => {
lifetimeRef.current = lifetime
return () => {
lifetimeRef.current = null
}
}, [lifetime])
const ready = target !== null && snapshot.ownerKey === ownerKey
const state = ready ? snapshot.state : EMPTY_QUICK_FILTER_STATE
useEffect(() => {
if (!model.taskResumeApplied || model.taskSource !== 'jira') {
return
}
let cancelled = false
setLoading(true)
setError(null)
const load = async () => {
try {
if (!target) {
return
}
const next = await readJiraQuickFilterState(target)
if (!cancelled) {
setSnapshot({ ownerKey, state: next })
}
} catch (reason) {
if (!cancelled) {
setError(
reason instanceof Error
? reason.message
: translate('jira.statusFilters.loadFailed', 'Could not load Jira status filters.')
)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [ownerKey, target, model.taskResumeApplied, model.taskSource, refresh])
const select = async (statuses: string[]): Promise<void> => {
if (
!ready ||
loading ||
writePendingRef.current === lifetime ||
!target ||
lifetimeRef.current !== lifetime
) {
return
}
const expectedOwner = ownerKey
const stillCurrent = () => lifetimeRef.current === lifetime
writePendingRef.current = lifetime
setPendingLifetime(lifetime)
setError(null)
try {
const next = await writeJiraQuickFilterState(target, { statuses })
if (stillCurrent()) {
setSnapshot({ ownerKey: expectedOwner, state: next })
}
} catch (reason) {
if (stillCurrent()) {
setError(
reason instanceof Error
? reason.message
: translate('jira.statusFilters.saveFailed', 'Could not save Jira status filters.')
)
}
throw reason
} finally {
if (stillCurrent()) {
writePendingRef.current = null
setPendingLifetime(null)
}
}
}
return {
selectedJiraStatuses: state.statuses,
jiraQuickFiltersReady: ready,
jiraQuickFiltersLoading: loading,
jiraQuickFiltersPending: pendingLifetime === lifetime,
jiraQuickFiltersError: error,
retryJiraQuickFilters: () => setRefresh((value) => value + 1),
selectJiraStatuses: select
}
}
+10
View File
@@ -20045,6 +20045,16 @@
"noMatch": "No matching filters.",
"loadFailed": "Could not load saved Jira filters.",
"saveFailed": "Could not save Jira filters."
},
"statusFilters": {
"loadFailed": "Could not load Jira status filters.",
"saveFailed": "Could not save Jira status filters.",
"manualQuery": "Status filters apply to preset views. Clear the search to use them.",
"label": "Filter Jira issues by status",
"clear": "Clear statuses",
"jql": "Status filter JQL",
"editJql": "Edit as JQL",
"catalogHint": "Statuses come from loaded issues. Clear statuses to discover others in this view."
}
}
}
+10
View File
@@ -19964,6 +19964,16 @@
"noMatch": "No hay filtros coincidentes.",
"loadFailed": "No se pudieron cargar los filtros guardados de Jira.",
"saveFailed": "No se pudieron guardar los filtros de Jira."
},
"statusFilters": {
"loadFailed": "No se pudieron cargar los filtros de estado de Jira.",
"saveFailed": "No se pudieron guardar los filtros de estado de Jira.",
"manualQuery": "Los filtros de estado se aplican a las vistas predefinidas. Borra la búsqueda para usarlos.",
"label": "Filtrar incidencias de Jira por estado",
"clear": "Borrar estados",
"jql": "JQL del filtro de estado",
"editJql": "Editar como JQL",
"catalogHint": "Los estados provienen de las incidencias cargadas. Borra los estados para descubrir otros en esta vista."
}
}
}
+10
View File
@@ -19933,6 +19933,16 @@
"noMatch": "Aucun filtre correspondant.",
"loadFailed": "Impossible de charger les filtres Jira enregistrés.",
"saveFailed": "Impossible d’enregistrer les filtres Jira."
},
"statusFilters": {
"loadFailed": "Impossible de charger les filtres de statut Jira.",
"saveFailed": "Impossible d’enregistrer les filtres de statut Jira.",
"manualQuery": "Les filtres de statut s’appliquent aux vues prédéfinies. Effacez la recherche pour les utiliser.",
"label": "Filtrer les tickets Jira par statut",
"clear": "Effacer les statuts",
"jql": "JQL du filtre de statut",
"editJql": "Modifier en JQL",
"catalogHint": "Les statuts proviennent des tickets chargés. Effacez les statuts pour en découvrir d’autres dans cette vue."
}
}
}
+10
View File
@@ -19933,6 +19933,16 @@
"noMatch": "一致するフィルターはありません。",
"loadFailed": "Jiraの保存済みフィルターを読み込めませんでした。",
"saveFailed": "Jiraフィルターを保存できませんでした。"
},
"statusFilters": {
"loadFailed": "Jira ステータスフィルターを読み込めませんでした。",
"saveFailed": "Jira ステータスフィルターを保存できませんでした。",
"manualQuery": "ステータスフィルターはプリセットビューに適用されます。使用するには検索をクリアしてください。",
"label": "ステータスで Jira 課題を絞り込む",
"clear": "ステータスをクリア",
"jql": "ステータスフィルターの JQL",
"editJql": "JQL として編集",
"catalogHint": "ステータスは読み込まれた課題から取得されます。このビューで他のステータスを表示するには、選択をクリアしてください。"
}
}
}
+10
View File
@@ -19933,6 +19933,16 @@
"noMatch": "일치하는 필터가 없습니다.",
"loadFailed": "저장된 Jira 필터를 불러올 수 없습니다.",
"saveFailed": "Jira 필터를 저장할 수 없습니다."
},
"statusFilters": {
"loadFailed": "Jira 상태 필터를 불러올 수 없습니다.",
"saveFailed": "Jira 상태 필터를 저장할 수 없습니다.",
"manualQuery": "상태 필터는 사전 설정 보기에 적용됩니다. 사용하려면 검색을 지우세요.",
"label": "상태별로 Jira 이슈 필터링",
"clear": "상태 지우기",
"jql": "상태 필터 JQL",
"editJql": "JQL로 편집",
"catalogHint": "상태는 불러온 이슈에서 가져옵니다. 이 보기에서 다른 상태를 찾으려면 선택을 지우세요."
}
}
}
+10
View File
@@ -19933,6 +19933,16 @@
"noMatch": "没有匹配的筛选器。",
"loadFailed": "无法加载已保存的 Jira 筛选器。",
"saveFailed": "无法保存 Jira 筛选器。"
},
"statusFilters": {
"loadFailed": "无法加载 Jira 状态筛选器。",
"saveFailed": "无法保存 Jira 状态筛选器。",
"manualQuery": "状态筛选器适用于预设视图。清除搜索后即可使用。",
"label": "按状态筛选 Jira 议题",
"clear": "清除状态",
"jql": "状态筛选器 JQL",
"editJql": "作为 JQL 编辑",
"catalogHint": "状态来自已加载的议题。清除状态选择以查看此视图中的其他状态。"
}
}
}
@@ -1,3 +1,7 @@
import {
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED
} from '../../../shared/jira-quick-filters'
import type {
JiraAuthType,
JiraComment,
@@ -5,6 +9,7 @@ import type {
JiraCreateField,
JiraIssue,
JiraIssueFilter,
JiraIssueListFilters,
JiraIssueType,
JiraIssueUpdate,
JiraMutationResult,
@@ -17,7 +22,7 @@ import type {
} from '../../../shared/jira-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { searchLocalJiraIssues } from './local-jira-search-cancellation'
import { callRuntimeRpc } from './runtime-rpc-client'
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
@@ -150,13 +155,32 @@ export async function jiraListIssues(
settings: RuntimeJiraSettings,
filter?: JiraIssueFilter,
limit?: number,
siteId?: JiraSiteSelection | null
siteId?: JiraSiteSelection | null,
filters?: JiraIssueListFilters
): Promise<JiraIssue[]> {
const target = getJiraRuntimeTarget(settings)
const args = { filter, limit, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
: window.api.jira.listIssues(args)
return jiraListIssuesForTarget(getJiraRuntimeTarget(settings), filter, limit, siteId, filters)
}
export async function jiraListIssuesForTarget(
target: RuntimeClientTarget,
filter?: JiraIssueFilter,
limit?: number,
siteId?: JiraSiteSelection | null,
filters?: JiraIssueListFilters
): Promise<JiraIssue[]> {
const args = { filter, limit, siteId: siteId ?? undefined, ...(filters ? { filters } : {}) }
if (target.kind === 'environment') {
if (filters?.statuses?.length) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
15_000
)
}
return callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
}
return window.api.jira.listIssues(args)
}
export async function jiraGetIssue(
@@ -0,0 +1,49 @@
import {
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
normalizeJiraQuickFilterState,
type JiraQuickFilterState
} from '../../../shared/jira-quick-filters'
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
export async function readJiraQuickFilterState(
target: RuntimeClientTarget
): Promise<JiraQuickFilterState> {
if (target.kind === 'environment') {
await assertRuntimeEnvironmentCapability(
target.environmentId,
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
15_000
)
return normalizeJiraQuickFilterState(
await callRuntimeRpc(target, 'ui.getJiraQuickFilters', undefined, { timeoutMs: 15_000 })
)
}
if (!window.api.ui.getJiraQuickFilters) {
throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
}
return normalizeJiraQuickFilterState(await window.api.ui.getJiraQuickFilters())
}
export async function writeJiraQuickFilterState(
target: RuntimeClientTarget,
state: JiraQuickFilterState
): Promise<JiraQuickFilterState> {
if (target.kind === 'environment') {
await assertRuntimeEnvironmentCapability(
target.environmentId,
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
15_000
)
return normalizeJiraQuickFilterState(
await callRuntimeRpc(target, 'ui.setJiraQuickFilters', state, { timeoutMs: 15_000 })
)
}
if (!window.api.ui.setJiraQuickFilters) {
throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
}
return normalizeJiraQuickFilterState(await window.api.ui.setJiraQuickFilters(state))
}
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { jiraListIssues, jiraListIssuesForTarget } from './runtime-jira-client'
import {
readJiraQuickFilterState,
writeJiraQuickFilterState
} from './runtime-jira-quick-filter-state'
import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from '../../../shared/jira-quick-filters'
const { assertCapability, rpc } = vi.hoisted(() => ({ assertCapability: vi.fn(), rpc: vi.fn() }))
vi.mock('./runtime-rpc-client', () => ({
assertRuntimeEnvironmentCapability: assertCapability,
callRuntimeRpc: rpc
}))
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
const target = { kind: 'environment', environmentId: 'jira-owner' } as const
it('keeps the old request shape when no statuses are selected', async () => {
rpc.mockResolvedValue([])
await jiraListIssues(target, 'all', 50, 'site-a')
expect(assertCapability).not.toHaveBeenCalled()
expect(rpc).toHaveBeenCalledWith(
target,
'jira.listIssues',
{ filter: 'all', limit: 50, siteId: 'site-a' },
{ timeoutMs: 30_000 }
)
})
it('gates status list filters on the called execution host', async () => {
rpc.mockResolvedValue([])
await jiraListIssuesForTarget(target, 'all', 50, 'site-a', { statuses: ['In Progress'] })
expect(assertCapability).toHaveBeenCalledWith(
'jira-owner',
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
expect.any(String),
15_000
)
expect(rpc).toHaveBeenCalledWith(
target,
'jira.listIssues',
{ filter: 'all', limit: 50, siteId: 'site-a', filters: { statuses: ['In Progress'] } },
{ timeoutMs: 30_000 }
)
})
it('refuses an older host without silently dropping selection or writing strict resume fields', async () => {
assertCapability.mockRejectedValue(new Error('Update the Jira host'))
await expect(
jiraListIssues(target, 'all', 50, 'site-a', { statuses: ['Blocked'] })
).rejects.toThrow('Update')
await expect(writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).rejects.toThrow(
'Update'
)
expect(rpc).not.toHaveBeenCalled()
})
it('routes persisted state through host UI methods and acknowledges the write', async () => {
rpc.mockResolvedValue({ statuses: ['Blocked'] })
expect(await readJiraQuickFilterState(target)).toEqual({ statuses: ['Blocked'] })
expect(await writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).toEqual({
statuses: ['Blocked']
})
expect(rpc).toHaveBeenLastCalledWith(
target,
'ui.setJiraQuickFilters',
{ statuses: ['Blocked'] },
{ timeoutMs: 15_000 }
)
})
it('uses local IPC and reports a lost write instead of pretending to save', async () => {
const read = vi.fn(async () => ({ statuses: ['Open'] }))
const write = vi.fn(async () => {
throw new Error('Lost host')
})
vi.stubGlobal('window', {
api: { ui: { getJiraQuickFilters: read, setJiraQuickFilters: write } }
})
expect(await readJiraQuickFilterState({ kind: 'local' })).toEqual({ statuses: ['Open'] })
await expect(writeJiraQuickFilterState({ kind: 'local' }, { statuses: [] })).rejects.toThrow(
'Lost host'
)
expect(rpc).not.toHaveBeenCalled()
})
@@ -1,4 +1,6 @@
import { jiraListIssues, jiraSearchIssues } from '@/runtime/runtime-jira-client'
import { normalizeJiraStatusSelection } from '../../../../shared/jira-quick-filters'
import { jiraListIssuesForTarget, jiraSearchIssues } from '@/runtime/runtime-jira-client'
import { getJiraRuntimeTarget } from '@/runtime/runtime-jira-target'
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
import type { JiraIssue, JiraSiteSelection } from '../../../../shared/jira-types'
import type { JiraSlice, JiraSliceGet, JiraSliceSet } from './jira-slice-contract'
@@ -148,9 +150,11 @@ export function createJiraCollectionReadActions(
listJiraIssues: async (filter = 'assigned', limit = 30, options) => {
const scope = getJiraReadScope(get().settings, options?.sourceContext)
const siteId = getSelectedJiraSiteId(get().jiraStatus)
const statuses = normalizeJiraStatusSelection(options?.filters?.statuses).sort()
const filters = statuses.length ? { statuses } : undefined
const cacheKey = scopedJiraCacheKey(
scope,
`${siteId ?? 'default'}::list::${filter}::${limit}`
`${siteId ?? 'default'}::list::${filter}::${limit}${filters ? `::${JSON.stringify(filters)}` : ''}`
)
const cached = get().jiraSearchCache[cacheKey]
if (!options?.force && isFreshJiraCacheEntry(cached)) {
@@ -167,7 +171,14 @@ export function createJiraCollectionReadActions(
return inflight.promise
}
let entry: InflightJiraReadRequest<JiraIssue[]>
const promise = jiraListIssues(scope.settings, filter, limit, siteId)
const request = jiraListIssuesForTarget(
getJiraRuntimeTarget(scope.settings),
filter,
limit,
siteId,
filters
)
const promise = request
.then((issues) => {
if (
inflightListRequests.get(cacheKey) === entry &&
@@ -6,6 +6,7 @@ import type {
JiraConnectionStatus,
JiraIssue,
JiraIssueFilter,
JiraIssueListFilters,
JiraSiteSelection,
JiraViewer
} from '../../../../shared/jira-types'
@@ -16,7 +17,10 @@ export type JiraReadOptions = {
siteId?: JiraSiteSelection | null
}
export type JiraCollectionReadOptions = JiraReadOptions & { force?: boolean }
export type JiraCollectionReadOptions = JiraReadOptions & {
force?: boolean
filters?: JiraIssueListFilters
}
export type JiraSearchOptions = JiraCollectionReadOptions & { signal?: AbortSignal }
export type JiraPatchOptions = {
sourceContext?: TaskSourceContext | null
@@ -0,0 +1,53 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { JiraIssue, JiraIssueListFilters } from '../../../../shared/jira-types'
import { createJiraSlice } from './jira'
const { list } = vi.hoisted(() => ({ list: vi.fn() }))
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraListIssues: list,
jiraListIssuesForTarget: list,
jiraSearchIssues: vi.fn()
}))
beforeEach(() => {
vi.resetAllMocks()
})
function issue(key: string): JiraIssue {
return {
id: key,
key,
title: key,
url: `https://jira.example/browse/${key}`,
project: { id: 'project', key: 'APP', name: 'App' },
issueType: { id: 'bug', name: 'Bug' },
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-10-01',
updatedAt: '2026-10-01'
}
}
it('isolates status results from preset caches and reuses an equivalent selection order', async () => {
const store = create<AppState>()(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only settings and Jira slice fields are accessed by these list reads.
(...args) => ({ settings: null, ...createJiraSlice(...args) }) as AppState
)
list.mockImplementation(
async (_source, _preset, _limit, _site, filters?: JiraIssueListFilters) => [
issue(filters?.statuses?.join('-') || 'default')
]
)
expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
expect(
await store.getState().listJiraIssues('all', 50, { filters: { statuses: ['Open'] } })
).toMatchObject([{ key: 'Open' }])
expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
expect(
await store
.getState()
.listJiraIssues('all', 50, { filters: { statuses: ['In Progress', 'Blocked'] } })
).toMatchObject([{ key: 'Blocked-In Progress' }])
await store
.getState()
.listJiraIssues('all', 50, { filters: { statuses: ['Blocked', 'In Progress'] } })
expect(list).toHaveBeenCalledTimes(3)
})
+8 -1
View File
@@ -34,6 +34,7 @@ vi.mock('@/runtime/runtime-jira-client', () => ({
jiraListCreateFields: vi.fn(),
jiraListIssueTypes: vi.fn(),
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
jiraListIssuesForTarget: (...args: unknown[]) => jiraListIssues(...args),
jiraReadStatus: (...args: unknown[]) => jiraReadStatus(...args),
jiraListPriorities: vi.fn(),
jiraListProjects: vi.fn(),
@@ -208,7 +209,13 @@ describe('createJiraSlice runtime context', () => {
sourceResult.resolve([{ ...issue('ALP-1'), title: 'Source issue' }])
await expect(request).resolves.toMatchObject([{ key: 'ALP-1', title: 'Source issue' }])
expect(jiraListIssues).toHaveBeenCalledWith(sourceContext, 'assigned', 30, 'site-1')
expect(jiraListIssues).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'source-runtime' },
'assigned',
30,
'site-1',
undefined
)
expect(Object.values(store.getState().jiraSearchCache)).toHaveLength(1)
expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined()
})
+4
View File
@@ -0,0 +1,4 @@
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { buildJiraPresetJql } from './jira-preset-jql'
import { normalizeJiraQuickFilterState } from './jira-quick-filters'
import { JiraIssueListFiltersParam } from './rpc-contract/jira-quick-filter-params'
describe('Jira preset status predicates', () => {
it.each([
['assigned', 'assignee = currentUser() AND statusCategory != Done'],
['reported', 'reporter = currentUser() AND statusCategory != Done'],
['all', 'statusCategory != Done'],
['done', 'assignee = currentUser() AND statusCategory = Done']
] as const)('preserves %s scope and combines statuses with OR', (preset, base) => {
expect(buildJiraPresetJql(preset)).toBe(`${base} ORDER BY updated DESC`)
expect(buildJiraPresetJql(preset, { statuses: ['Blocked', 'In Progress'] })).toBe(
`${base} AND status in ("Blocked", "In Progress") ORDER BY updated DESC`
)
})
it('escapes quoted status names without treating ORDER BY as syntax', () => {
expect(
buildJiraPresetJql('all', { statuses: ['Needs "QA" \\ Review', 'ORDER BY cleanup'] })
).toBe(
'statusCategory != Done AND status in ("Needs \\"QA\\" \\\\ Review", "ORDER BY cleanup") ORDER BY updated DESC'
)
})
it('bounds persisted state while preserving actual status names', () => {
expect(
normalizeJiraQuickFilterState({
statuses: [' In Progress ', 'Done', 'Done', 42, '', 'x'.repeat(129)]
})
).toEqual({ statuses: [' In Progress ', 'Done'] })
expect(normalizeJiraQuickFilterState(null)).toEqual({ statuses: [] })
expect(
JiraIssueListFiltersParam.safeParse({ statuses: Array.from({ length: 51 }, () => 'Open') })
.success
).toBe(false)
expect(JiraIssueListFiltersParam.safeParse({ statuses: [' '] }).success).toBe(false)
})
})
+17 -10
View File
@@ -1,14 +1,21 @@
import type { JiraIssueFilter } from './jira-types'
import type { JiraIssueFilter, JiraIssueListFilters } from './jira-types'
import { normalizeJiraStatusSelection } from './jira-quick-filters'
export function buildJiraPresetJql(filter: JiraIssueFilter): string {
if (filter === 'assigned') {
return 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
export function buildJiraPresetJql(
filter: JiraIssueFilter,
filters?: JiraIssueListFilters
): string {
const clauses: string[] = []
if (filter === 'assigned' || filter === 'done') {
clauses.push('assignee = currentUser()')
} else if (filter === 'reported') {
clauses.push('reporter = currentUser()')
}
if (filter === 'reported') {
return 'reporter = currentUser() AND statusCategory != Done ORDER BY updated DESC'
clauses.push(filter === 'done' ? 'statusCategory = Done' : 'statusCategory != Done')
const statuses = normalizeJiraStatusSelection(filters?.statuses)
if (statuses.length > 0) {
const names = statuses.map((name) => `"${name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
clauses.push(`status in (${names.join(', ')})`)
}
if (filter === 'done') {
return 'assignee = currentUser() AND statusCategory = Done ORDER BY updated DESC'
}
return 'statusCategory != Done ORDER BY updated DESC'
return `${clauses.join(' AND ')} ORDER BY updated DESC`
}
+30
View File
@@ -0,0 +1,30 @@
export const JIRA_STATUS_FILTER_RUNTIME_CAPABILITY = 'jira.issue-list-statuses.v1' as const
export const JIRA_STATUS_FILTER_UPDATE_REQUIRED = 'Update the Jira host to use status filters.'
export const MAX_JIRA_STATUS_FILTERS = 50
export const MAX_JIRA_STATUS_NAME_LENGTH = 128
export type JiraQuickFilterState = { statuses: string[] }
export function normalizeJiraStatusSelection(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return [
...new Set(
value.filter(
(item): item is string =>
typeof item === 'string' &&
item.trim().length > 0 &&
item.length <= MAX_JIRA_STATUS_NAME_LENGTH
)
)
].slice(0, MAX_JIRA_STATUS_FILTERS)
}
export function normalizeJiraQuickFilterState(value: unknown): JiraQuickFilterState {
return {
statuses: normalizeJiraStatusSelection(
value && typeof value === 'object' && 'statuses' in value ? value.statuses : undefined
)
}
}
+2
View File
@@ -149,6 +149,8 @@ export type JiraIssueUpdate = {
export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
export type JiraIssueListFilters = { statuses?: string[] }
export type JiraConnectArgs = {
siteUrl: string
// Empty for Bearer authentication: self-hosted PAT or scoped Cloud token.
+2
View File
@@ -1,3 +1,4 @@
import type { JiraQuickFilterState } from './jira-quick-filters'
import type { JiraSavedFilterState } from './jira-saved-filters'
import type { ReleaseChannel } from './release-channel'
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
@@ -29,6 +30,7 @@ import type { PersistedAutomationHostFilter } from './automation-host-filter'
export type PersistedUIState = {
jiraSavedFilterState?: JiraSavedFilterState
jiraQuickFilterState?: JiraQuickFilterState
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
/** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */
+6 -7
View File
@@ -1,5 +1,8 @@
import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability'
import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters'
import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability'
import * as jiraDetailCapabilities from './jira-detail-capabilities'
export * from './jira-detail-capabilities'
import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities'
import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from './ssh-target-management'
import {
@@ -103,11 +106,6 @@ export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY =
'folder-workspace.path-status.v1' as const
export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY =
'linear.issue-attribute-filter.v1' as const
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
// Why: signals the host exposes the Agent Session History scanner over RPC
// (aiVault.listSessions). Registered unconditionally for every build, so it is a
// STATIC capability advertised by getStatus() automatically — NOT a runtime
@@ -388,9 +386,10 @@ export const RUNTIME_CAPABILITIES = [
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
AI_VAULT_RUNTIME_CAPABILITY,
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
+3 -1
View File
@@ -1,3 +1,4 @@
import { JiraIssueListFiltersParam } from './jira-quick-filter-params'
import { z } from 'zod'
import {
OptionalFiniteNumber,
@@ -39,7 +40,8 @@ export const ListIssues = z
filter: z.enum(VALID_FILTERS).optional(),
limit: OptionalFiniteNumber,
siteId: OptionalString,
strict: z.boolean().optional()
strict: z.boolean().optional(),
filters: JiraIssueListFiltersParam.optional()
})
.optional()
@@ -0,0 +1,15 @@
import { z } from 'zod'
import { MAX_JIRA_STATUS_FILTERS, MAX_JIRA_STATUS_NAME_LENGTH } from '../jira-quick-filters'
const JiraStatuses = z
.array(
z
.string()
.min(1)
.max(MAX_JIRA_STATUS_NAME_LENGTH)
.refine((name) => name.trim().length > 0)
)
.max(MAX_JIRA_STATUS_FILTERS)
export const JiraQuickFilterStateParam = z.strictObject({ statuses: JiraStatuses })
export const JiraIssueListFiltersParam = z.strictObject({ statuses: JiraStatuses.optional() })
+3
View File
@@ -332,6 +332,7 @@ import {
SiteSelection,
UserSearch
} from './jira-params'
import { JiraQuickFilterStateParam } from './jira-quick-filter-params'
import { JiraSavedFilterMutationParam } from './jira-saved-filter-params'
import {
AgentIssueContext,
@@ -1279,10 +1280,12 @@ export const RPC_PARAMS_BY_METHOD = {
'terminalDaemon.restartSevered': TerminalDaemonRestartSeveredParams,
'terminalDaemon.severedStatus': null,
'ui.get': null,
'ui.getJiraQuickFilters': null,
'ui.getJiraSavedFilters': null,
'ui.mutateJiraSavedFilters': JiraSavedFilterMutationParam,
'ui.recordFeatureInteraction': FeatureInteractionIdParam,
'ui.set': UiUpdate,
'ui.setJiraQuickFilters': JiraQuickFilterStateParam,
'updater.check': UpdaterCheckParams,
'updater.download': null,
'updater.getStatus': null,
@@ -64,6 +64,7 @@ export const UI_STATE_DISPOSITIONS = {
item: 38,
note: "A 'local' filter also covers the WSL host."
},
jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'),
jiraSavedFilterState: notApplicableDisposition(
38,
'Filters belong to the Jira host, not a moved workspace.'