diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 6d0db96c1a2..581262793cb 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -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 + ) } ) diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index cc76aaada59..93c2150e71e 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -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) ) diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts index d817e2abaaf..59f7d87675b 100644 --- a/src/main/jira/jira-issue-search.ts +++ b/src/main/jira/jira-issue-search.ts @@ -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 { - return runIssueSearch({ filter }, limit, siteId, undefined, strict) + return runIssueSearch({ filter, filters }, limit, siteId, undefined, strict) } export async function searchIssues( diff --git a/src/main/jira/jira-preset-filters.test.ts b/src/main/jira/jira-preset-filters.test.ts index 648c19cee9d..c6a46491c19 100644 --- a/src/main/jira/jira-preset-filters.test.ts +++ b/src/main/jira/jira-preset-filters.test.ts @@ -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: [] }) diff --git a/src/main/persistence-jira-saved-filters.test.ts b/src/main/persistence-jira-saved-filters.test.ts index 565621de629..3271c1f4f06 100644 --- a/src/main/persistence-jira-saved-filters.test.ts +++ b/src/main/persistence-jira-saved-filters.test.ts @@ -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'] }) }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index c64749c3d75..23a7eb3e067 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts b/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts index 7fdf478647b..c57d5ef3be3 100644 --- a/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts +++ b/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts @@ -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') +}) diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 4c3fae446f3..228109b8417 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -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' }) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 6460e1518d4..51ffffb0306 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index 8273895346f..17fb5523886 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -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' diff --git a/src/main/runtime/runtime-jira-commands.ts b/src/main/runtime/runtime-jira-commands.ts index ac4353007f9..c30ba5c34a9 100644 --- a/src/main/runtime/runtime-jira-commands.ts +++ b/src/main/runtime/runtime-jira-commands.ts @@ -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 { - 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 { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index d88eb71f603..7587fd5260c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -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', diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 813ef436b99..f8e965d5384 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -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 getIssue: (args: { key: string; siteId?: string }) => Promise lookupIssueSummary: (args: { diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index d7720c3ab07..f58b51334a1 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -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'), diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 9f4e0c397b0..a00a24dbfe4 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -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 + setJiraQuickFilters?: (state: JiraQuickFilterState) => Promise getJiraSavedFilters?: () => Promise mutateJiraSavedFilters?: (mutation: JiraSavedFilterMutation) => Promise get: () => Promise diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx new file mode 100644 index 00000000000..e5add67d0bd --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx @@ -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() + 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() + 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() + 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() + expect(container.innerHTML).toBe('') + expect(state.selectJiraStatuses).not.toHaveBeenCalled() + expect(state.selectedJiraStatuses).toEqual(['Developed']) +}) diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx new file mode 100644 index 00000000000..aebedd43952 --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx @@ -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 ? ( +

+ {translate( + 'jira.statusFilters.manualQuery', + 'Status filters apply to preset views. Clear the search to use them.' + )} +

+ ) : null + } + return ( +
+ {jiraQuickFiltersError ? ( +
+ {jiraQuickFiltersError} + +
+ ) : null} + {statuses.length ? ( +
+ { + void selectJiraStatuses(next).catch(() => {}) + }} + className="flex-wrap" + > + {statuses.map((status) => ( + + {status.name} + + ))} + + {selectedJiraStatuses.length ? ( + + ) : null} +
+ ) : null} + {selectedJiraStatuses.length ? ( + <> +
+ + +
+

+ {translate( + 'jira.statusFilters.catalogHint', + 'Statuses come from loaded issues. Clear statuses to discover others in this view.' + )} +

+ + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/task-page-jira-status-catalog.test.ts b/src/renderer/src/components/task-page-jira-status-catalog.test.ts new file mode 100644 index 00000000000..4aeac5532d4 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-catalog.test.ts @@ -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' } + ]) +}) diff --git a/src/renderer/src/components/task-page-jira-status-catalog.ts b/src/renderer/src/components/task-page-jira-status-catalog.ts new file mode 100644 index 00000000000..bde6f159a7e --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-catalog.ts @@ -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() + 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' })) + ]) +} diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx index 0cdb4356feb..1ba65cbc300 100644 --- a/src/renderer/src/components/task-page/jira/Filters.tsx +++ b/src/renderer/src/components/task-page/jira/Filters.tsx @@ -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}

) : null} +
diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts index 76bbc47e0a3..87f2d0bfb17 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts @@ -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' diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts index 060f8376027..6b3945b450e 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts @@ -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 diff --git a/src/renderer/src/components/use-task-page-jira-list-state.ts b/src/renderer/src/components/use-task-page-jira-list-state.ts index d734fd9902c..0c6d3053d10 100644 --- a/src/renderer/src/components/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/use-task-page-jira-list-state.ts @@ -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('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, diff --git a/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx new file mode 100644 index 00000000000..8264e1814ad --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx @@ -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[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 = 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 = 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) + } +) diff --git a/src/renderer/src/components/use-task-page-jira-quick-filters.ts b/src/renderer/src/components/use-task-page-jira-quick-filters.ts new file mode 100644 index 00000000000..c8583e7d128 --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-quick-filters.ts @@ -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(null) + const [error, setError] = useState(null) + const [refresh, setRefresh] = useState(0) + const lifetimeRef = useRef(null) + const writePendingRef = useRef(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 => { + 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 + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4c931800c77..c7d063e0588 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b6ad38595dd..4b4a2424536 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c060d3d1602..be2e8712ee5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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." } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 36fe66a128d..c506df1cadb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19933,6 +19933,16 @@ "noMatch": "一致するフィルターはありません。", "loadFailed": "Jiraの保存済みフィルターを読み込めませんでした。", "saveFailed": "Jiraフィルターを保存できませんでした。" + }, + "statusFilters": { + "loadFailed": "Jira ステータスフィルターを読み込めませんでした。", + "saveFailed": "Jira ステータスフィルターを保存できませんでした。", + "manualQuery": "ステータスフィルターはプリセットビューに適用されます。使用するには検索をクリアしてください。", + "label": "ステータスで Jira 課題を絞り込む", + "clear": "ステータスをクリア", + "jql": "ステータスフィルターの JQL", + "editJql": "JQL として編集", + "catalogHint": "ステータスは読み込まれた課題から取得されます。このビューで他のステータスを表示するには、選択をクリアしてください。" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5c6da385e55..ca8c8aa97ff 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19933,6 +19933,16 @@ "noMatch": "일치하는 필터가 없습니다.", "loadFailed": "저장된 Jira 필터를 불러올 수 없습니다.", "saveFailed": "Jira 필터를 저장할 수 없습니다." + }, + "statusFilters": { + "loadFailed": "Jira 상태 필터를 불러올 수 없습니다.", + "saveFailed": "Jira 상태 필터를 저장할 수 없습니다.", + "manualQuery": "상태 필터는 사전 설정 보기에 적용됩니다. 사용하려면 검색을 지우세요.", + "label": "상태별로 Jira 이슈 필터링", + "clear": "상태 지우기", + "jql": "상태 필터 JQL", + "editJql": "JQL로 편집", + "catalogHint": "상태는 불러온 이슈에서 가져옵니다. 이 보기에서 다른 상태를 찾으려면 선택을 지우세요." } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1a0086e5820..bae98dd0f02 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19933,6 +19933,16 @@ "noMatch": "没有匹配的筛选器。", "loadFailed": "无法加载已保存的 Jira 筛选器。", "saveFailed": "无法保存 Jira 筛选器。" + }, + "statusFilters": { + "loadFailed": "无法加载 Jira 状态筛选器。", + "saveFailed": "无法保存 Jira 状态筛选器。", + "manualQuery": "状态筛选器适用于预设视图。清除搜索后即可使用。", + "label": "按状态筛选 Jira 议题", + "clear": "清除状态", + "jql": "状态筛选器 JQL", + "editJql": "作为 JQL 编辑", + "catalogHint": "状态来自已加载的议题。清除状态选择以查看此视图中的其他状态。" } } } diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 68fa5e71657..80f745869d7 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -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 { - const target = getJiraRuntimeTarget(settings) - const args = { filter, limit, siteId: siteId ?? undefined } - return target.kind === 'environment' - ? callRuntimeRpc(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 { + 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(target, 'jira.listIssues', args, { timeoutMs: 30_000 }) + } + return window.api.jira.listIssues(args) } export async function jiraGetIssue( diff --git a/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts b/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts new file mode 100644 index 00000000000..589aae4541e --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts @@ -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 { + 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 { + 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)) +} diff --git a/src/renderer/src/runtime/runtime-jira-status-filters.test.ts b/src/renderer/src/runtime/runtime-jira-status-filters.test.ts new file mode 100644 index 00000000000..8d98c505a7f --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-status-filters.test.ts @@ -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() +}) diff --git a/src/renderer/src/store/slices/jira-collection-read-actions.ts b/src/renderer/src/store/slices/jira-collection-read-actions.ts index b79d358cea5..fa1f6b5836d 100644 --- a/src/renderer/src/store/slices/jira-collection-read-actions.ts +++ b/src/renderer/src/store/slices/jira-collection-read-actions.ts @@ -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 - 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 && diff --git a/src/renderer/src/store/slices/jira-slice-contract.ts b/src/renderer/src/store/slices/jira-slice-contract.ts index 88492f8e0cc..66e80aac7af 100644 --- a/src/renderer/src/store/slices/jira-slice-contract.ts +++ b/src/renderer/src/store/slices/jira-slice-contract.ts @@ -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 diff --git a/src/renderer/src/store/slices/jira-status-filter-cache.test.ts b/src/renderer/src/store/slices/jira-status-filter-cache.test.ts new file mode 100644 index 00000000000..0f887087d8f --- /dev/null +++ b/src/renderer/src/store/slices/jira-status-filter-cache.test.ts @@ -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()( + // 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) +}) diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 0867b1e3045..1e7ec42a3ff 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -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() }) diff --git a/src/shared/jira-detail-capabilities.ts b/src/shared/jira-detail-capabilities.ts new file mode 100644 index 00000000000..6b1d3852a9a --- /dev/null +++ b/src/shared/jira-detail-capabilities.ts @@ -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.' diff --git a/src/shared/jira-preset-jql.test.ts b/src/shared/jira-preset-jql.test.ts new file mode 100644 index 00000000000..28970b38c82 --- /dev/null +++ b/src/shared/jira-preset-jql.test.ts @@ -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) + }) +}) diff --git a/src/shared/jira-preset-jql.ts b/src/shared/jira-preset-jql.ts index bc01d439139..b59de9efd00 100644 --- a/src/shared/jira-preset-jql.ts +++ b/src/shared/jira-preset-jql.ts @@ -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` } diff --git a/src/shared/jira-quick-filters.ts b/src/shared/jira-quick-filters.ts new file mode 100644 index 00000000000..93a9dc5db9f --- /dev/null +++ b/src/shared/jira-quick-filters.ts @@ -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 + ) + } +} diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index df768f99291..abd05bed47c 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -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. diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 1a7c5f59a38..8aeeaf36139 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -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. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index a1636f4f72e..c31ed7cfd12 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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, diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts index b37863bb5e8..b70f75c6b15 100644 --- a/src/shared/rpc-contract/jira-params.ts +++ b/src/shared/rpc-contract/jira-params.ts @@ -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() diff --git a/src/shared/rpc-contract/jira-quick-filter-params.ts b/src/shared/rpc-contract/jira-quick-filter-params.ts new file mode 100644 index 00000000000..51222f00725 --- /dev/null +++ b/src/shared/rpc-contract/jira-quick-filter-params.ts @@ -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() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 6a112381baa..69d35eee193 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts index a30cea32c31..352e5092807 100644 --- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts @@ -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.'