From 4731172bd8630b0ff0c2e4b9d2a62e60826506dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:39:31 -0700 Subject: [PATCH] feat(jira): choose planning columns in the issue list (#28615) Choose and sort Jira planning columns while bounding optional field discovery, preserving complete Cloud searches and aligning copy/workspace actions in responsive rows. Co-authored-by: Dagan --- config/focus-setting-read-baseline.txt | 3 +- src/main/jira/issues.test.ts | 5 + src/main/jira/jira-agent-read.test.ts | 4 + src/main/jira/jira-agile-field-search.test.ts | 196 +++++++++++++ src/main/jira/jira-agile-fields.test.ts | 228 +++++++++++++++ src/main/jira/jira-agile-fields.ts | 109 ++++++++ .../jira-cloud-planning-composition.test.ts | 264 ++++++++++++++++++ src/main/jira/jira-issue-mapping.ts | 59 +++- src/main/jira/jira-issue-read.ts | 16 +- src/main/jira/jira-issue-search.ts | 22 +- src/main/jira/jira-preset-filters.test.ts | 4 + src/main/jira/jira-search-abort.test.ts | 5 + src/renderer/src/assets/main.css | 9 + .../src/components/jira-issue-sorter.ts | 13 +- .../src/components/jira-list-columns-menu.tsx | 53 ++++ .../src/components/jira-list-columns.test.ts | 70 +++++ .../src/components/jira-list-columns.ts | 173 ++++++++++++ .../task-page-jira-grouping.test.ts | 2 + .../components/task-page-jira-issue-cells.tsx | 148 ++++++++++ .../components/task-page-jira-issue-list.tsx | 79 ++---- .../task-page-jira-quick-copy.test.tsx | 2 + .../task-page-jira-sort-controls.test.tsx | 8 +- .../task-page-jira-sort-controls.tsx | 55 ++-- .../components/task-page-jira-sorting.test.ts | 34 +++ .../components/task-page-jira-status-tone.ts | 22 ++ .../task-page-jira-workspace-actions.test.tsx | 2 + .../src/components/task-page/jira/Content.tsx | 34 ++- ...e-task-page-jira-list-state.owner.test.tsx | 164 +++++++++++ .../use-task-page-jira-list-state.ts | 108 +++++-- src/renderer/src/i18n/locales/en.json | 6 + .../src/runtime/runtime-jira-client.ts | 1 - .../runtime-jira-priority-client.test.ts | 74 +++++ src/shared/jira-types.ts | 6 + tests/e2e/jira-panel-layout.spec.ts | 85 +++++- 34 files changed, 1925 insertions(+), 138 deletions(-) create mode 100644 src/main/jira/jira-agile-field-search.test.ts create mode 100644 src/main/jira/jira-agile-fields.test.ts create mode 100644 src/main/jira/jira-agile-fields.ts create mode 100644 src/main/jira/jira-cloud-planning-composition.test.ts create mode 100644 src/renderer/src/components/jira-list-columns-menu.tsx create mode 100644 src/renderer/src/components/jira-list-columns.test.ts create mode 100644 src/renderer/src/components/jira-list-columns.ts create mode 100644 src/renderer/src/components/task-page-jira-issue-cells.tsx create mode 100644 src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx create mode 100644 src/renderer/src/runtime/runtime-jira-priority-client.test.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 08e767e4bc5..e4a91013899 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -219,7 +219,7 @@ 1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts 1 src/renderer/src/components/task-page-jira-status-order.ts 2 src/renderer/src/components/task-page-linear-issue-model.tsx -11 src/renderer/src/components/task-page/TaskPage.tsx +10 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx 1 src/renderer/src/components/task-page/linear/Filters.tsx 1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -255,7 +255,6 @@ 4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts 4 src/renderer/src/components/use-task-page-jira-issue-creation.ts 2 src/renderer/src/components/use-task-page-jira-list-effects.ts -2 src/renderer/src/components/use-task-page-jira-list-state.ts 4 src/renderer/src/components/use-task-page-linear-board.ts 6 src/renderer/src/components/use-task-page-linear-creation-state.ts 4 src/renderer/src/components/use-task-page-linear-issue-creation.ts diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index e7fecbe60da..a8c2f235715 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -21,6 +21,11 @@ const { releaseMock: vi.fn() })) +vi.mock('./jira-agile-fields', () => ({ + getAgileFieldIds: async () => ({}), + agileFieldIdList: () => [] +})) + vi.mock('./request-queue', () => ({ acquire: acquireMock, release: releaseMock })) vi.mock('./authenticated-request', () => ({ diff --git a/src/main/jira/jira-agent-read.test.ts b/src/main/jira/jira-agent-read.test.ts index bfc563d3069..6148da74b51 100644 --- a/src/main/jira/jira-agent-read.test.ts +++ b/src/main/jira/jira-agent-read.test.ts @@ -13,6 +13,10 @@ vi.mock('./authenticated-request', () => ({ jiraRequestBinary: vi.fn(), JiraApiError: class extends Error {} })) +vi.mock('./jira-agile-fields', () => ({ + getAgileFieldIds: async () => ({}), + agileFieldIdList: () => [] +})) vi.mock('./request-queue', () => ({ acquire, release })) import { listIssues, searchIssues } from './jira-issue-search' diff --git a/src/main/jira/jira-agile-field-search.test.ts b/src/main/jira/jira-agile-field-search.test.ts new file mode 100644 index 00000000000..c8556257875 --- /dev/null +++ b/src/main/jira/jira-agile-field-search.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' +import { searchIssues } from './jira-issue-search' +import { getIssue } from './jira-issue-read' + +const { getClientsMock, jiraRequestMock } = vi.hoisted(() => ({ + getClientsMock: vi.fn(), + jiraRequestMock: vi.fn() +})) +vi.mock('./client', () => ({ + getClients: (...args: unknown[]) => getClientsMock(...args), + clearToken: vi.fn(), + isAuthError: () => false +})) +vi.mock('./authenticated-request', () => ({ + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) +})) + +function client(id: string): JiraClientForSite { + return { + site: { + id, + siteUrl: `https://${id}.atlassian.net`, + email: '', + displayName: id, + accountId: 'viewer' + }, + authorization: 'Basic fixture' + } +} + +describe('planning fields in Jira searches', () => { + it('requests all discovered points fields and maps the populated project field', async () => { + getClientsMock.mockReturnValue([client('field-request-site')]) + jiraRequestMock.mockImplementation(async (_entry, path) => + path.endsWith('/field') + ? [ + { + id: 'customfield_7', + name: 'Sprint', + schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } + }, + { + id: 'customfield_8', + name: 'Story Points', + schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' } + }, + { + id: 'customfield_9', + name: 'Story point estimate', + schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' } + } + ] + : { + issues: [ + { + id: '1', + key: 'DEMO-1', + fields: { + summary: 'Planning', + customfield_7: [{ name: 'Sprint 2', state: 'active' }], + customfield_8: null, + customfield_9: 0, + timeoriginalestimate: 3600, + timeestimate: 0, + parent: { key: 'DEMO-10', fields: { summary: 'Epic' } } + } + } + ] + } + ) + const issues = await searchIssues('project = DEMO', 30, 'field-request-site') + expect(issues[0]).toMatchObject({ + sprint: 'Sprint 2', + storyPoints: 0, + originalEstimateSeconds: 3600, + remainingEstimateSeconds: 0, + parent: { key: 'DEMO-10', title: 'Epic' } + }) + const init = jiraRequestMock.mock.calls.find((call) => call[1].endsWith('/search/jql'))?.[2] + expect(JSON.parse(init.body).fields).toEqual( + expect.arrayContaining([ + 'parent', + 'timeoriginalestimate', + 'timeestimate', + 'customfield_7', + 'customfield_8', + 'customfield_9' + ]) + ) + }) + + it('does not deadlock when more sites than queue slots discover fields together', async () => { + const clients = Array.from({ length: 6 }, (_, index) => client(`queue-site-${index}`)) + getClientsMock.mockReturnValue(clients) + jiraRequestMock + .mockClear() + .mockImplementation(async (_entry, path) => + path.endsWith('/field') + ? [] + : { issues: [{ key: 'DEMO-1', fields: { summary: 'Planning' } }] } + ) + expect(await searchIssues('project = DEMO', 30, 'all')).toHaveLength(6) + expect(jiraRequestMock).toHaveBeenCalledTimes(12) + }) + + it.each(['search', 'detail'] as const)( + 'keeps ordinary Server %s reads available when optional discovery hangs', + async (kind) => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const entry = client(`hung-${kind}`) + entry.site.authType = 'server' + getClientsMock.mockReturnValue([entry]) + jiraRequestMock + .mockClear() + .mockImplementation((_entry, path) => + path.endsWith('/field') + ? new Promise(() => {}) + : Promise.resolve( + path.includes('/search') + ? { issues: [{ key: 'DEMO-1', fields: { summary: 'Still available' } }] } + : { key: 'DEMO-1', fields: { summary: 'Still available' } } + ) + ) + const read = () => + kind === 'search' + ? searchIssues('project = DEMO', 30, entry.site.id) + : getIssue('DEMO-1', entry.site.id) + const first = read() + await vi.advanceTimersByTimeAsync(500) + expect(await first).toMatchObject( + kind === 'search' ? [{ title: 'Still available' }] : { title: 'Still available' } + ) + expect(jiraRequestMock.mock.calls[0][2].signal.aborted).toBe(true) + const before = jiraRequestMock.mock.calls.length + await read() + expect(jiraRequestMock.mock.calls.length).toBe(before + 1) + expect( + jiraRequestMock.mock.calls.filter((call) => call[1].endsWith('/field')) + ).toHaveLength(1) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + } + ) + + it('preserves planning fields when a list issue is hydrated from its detail read', async () => { + getClientsMock.mockReturnValue([client('detail-field-site')]) + jiraRequestMock.mockClear().mockImplementation(async (_entry, path) => + path.endsWith('/field') + ? [ + { + id: 'customfield_4', + name: 'Sprint', + schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } + }, + { + id: 'customfield_5', + name: 'Points', + schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' } + } + ] + : { + id: '1', + key: 'DEMO-1', + fields: { + summary: 'Planning', + description: 'Detailed planning notes', + customfield_4: [{ name: 'Sprint 3', state: 'active' }], + customfield_5: 0, + timeoriginalestimate: 7200, + timeestimate: 0, + parent: { key: 'DEMO-10', fields: { summary: 'Epic' } } + } + } + ) + expect(await getIssue('DEMO-1', 'detail-field-site')).toMatchObject({ + description: 'Detailed planning notes', + sprint: 'Sprint 3', + storyPoints: 0, + originalEstimateSeconds: 7200, + remainingEstimateSeconds: 0, + parent: { key: 'DEMO-10', title: 'Epic' } + }) + const detailPath = jiraRequestMock.mock.calls.find((call) => call[1].includes('/issue/'))?.[1] + const fields = new URL(detailPath, 'https://example.atlassian.net').searchParams.get('fields') + expect(fields?.split(',')).toEqual( + expect.arrayContaining(['description', 'customfield_4', 'customfield_5']) + ) + }) +}) diff --git a/src/main/jira/jira-agile-fields.test.ts b/src/main/jira/jira-agile-fields.test.ts new file mode 100644 index 00000000000..1cd92e86eef --- /dev/null +++ b/src/main/jira/jira-agile-fields.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' +import { getAgileFieldIds, pickAgileFieldIds } from './jira-agile-fields' +import { mapJiraIssue, mapSprint } from './jira-issue-mapping' +import type { JiraSite } from '../../shared/jira-types' + +const { jiraRequestMock, acquireMock, releaseMock } = vi.hoisted(() => ({ + jiraRequestMock: vi.fn(), + acquireMock: vi.fn().mockResolvedValue(undefined), + releaseMock: vi.fn() +})) + +vi.mock('./request-queue', () => ({ acquire: acquireMock, release: releaseMock })) +vi.mock('./authenticated-request', () => ({ + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) +})) + +const site: JiraSite = { + id: 'site', + siteUrl: 'https://example.atlassian.net', + email: 'dev@example.com', + displayName: 'Site', + accountId: 'acc-1', + authType: 'cloud' +} + +function clientFor(id: string): JiraClientForSite { + return { site: { ...site, id }, authorization: 'Basic token' } +} + +describe('jira agile fields', () => { + it('picks sprint and story point custom field ids from the field catalog', () => { + expect( + pickAgileFieldIds([ + { + id: 'customfield_1', + name: 'Sprint', + schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } + }, + { + id: 'customfield_2', + name: 'Budget', + schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' } + }, + { + id: 'customfield_3', + name: 'Story Points', + schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' } + } + ]) + ).toEqual({ sprint: 'customfield_1', storyPoints: ['customfield_3'] }) + }) + + it('discovers company and team managed points independently of a translated team field name', () => { + expect( + pickAgileFieldIds([ + { + id: 'customfield_1', + name: 'Story Points', + schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' } + }, + { + id: 'customfield_2', + name: 'Schätzung', + schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' } + }, + { id: '', name: 'Sprint', schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } } + ]) + ).toEqual({ storyPoints: ['customfield_1', 'customfield_2'] }) + }) + + it('prefers the active sprint and parses Server sprint strings', () => { + expect( + mapSprint([ + { name: 'Sprint 1', state: 'closed' }, + { name: 'Sprint 2', state: 'active' }, + { name: 'Sprint 3', state: 'future' } + ]) + ).toBe('Sprint 2') + // Server orders sprints by id, so the active one can sit between closed ones. + expect( + mapSprint([ + 'com.atlassian.greenhopper.service.sprint.Sprint@1[id=3,state=CLOSED,name=Sprint 3,goal=]', + 'com.atlassian.greenhopper.service.sprint.Sprint@2[id=4,state=ACTIVE,name=Sprint 4,goal=]', + 'com.atlassian.greenhopper.service.sprint.Sprint@3[id=5,state=CLOSED,name=Sprint 5,goal=]' + ]) + ).toBe('Sprint 4') + expect(mapSprint(null)).toBeUndefined() + }) + + it('keeps commas and brackets in Server sprint names', () => { + expect( + mapSprint('Sprint@1[id=1,state=ACTIVE,name=API, UI [October],startDate=,goal=]') + ).toBe('API, UI [October]') + expect(mapSprint('Sprint@1[id=1,state=ACTIVE,name=API, UI [October]]')).toBe( + 'API, UI [October]' + ) + }) + + describe('getAgileFieldIds', () => { + beforeEach(() => { + jiraRequestMock.mockReset() + acquireMock.mockClear() + releaseMock.mockClear() + }) + + it('requests the field catalog once per site inside the request queue', async () => { + jiraRequestMock.mockResolvedValue([ + { + id: 'customfield_7', + name: 'Sprint', + schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } + } + ]) + const client = clientFor('cached-site') + const first = await getAgileFieldIds(client) + const second = await getAgileFieldIds(client) + expect(first).toEqual({ sprint: 'customfield_7' }) + expect(second).toBe(first) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + expect(jiraRequestMock.mock.calls[0]?.[1]).toBe('/rest/api/3/field') + expect(acquireMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('refreshes a successful catalog after its bounded lifetime', async () => { + jiraRequestMock.mockResolvedValue([]) + const client = clientFor('expired-success') + await getAgileFieldIds(client) + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60_000) + await getAgileFieldIds(client) + expect(jiraRequestMock).toHaveBeenCalledTimes(2) + now.mockRestore() + }) + + it('isolates credentials and Server catalog paths', async () => { + jiraRequestMock.mockResolvedValue([]) + const first = clientFor('isolation-site') + await getAgileFieldIds(first) + await getAgileFieldIds({ ...first, authorization: 'Basic changed-token' }) + await getAgileFieldIds({ ...first, site: { ...first.site, authType: 'server' } }) + expect(jiraRequestMock).toHaveBeenCalledTimes(3) + expect(jiraRequestMock.mock.calls[2]?.[1]).toBe('/rest/api/2/field') + }) + + it('shares discovery without one cancelled caller abandoning another', async () => { + const response = Promise.withResolvers() + jiraRequestMock.mockReturnValue(response.promise) + const client = clientFor('concurrent-site') + const controller = new AbortController() + const first = getAgileFieldIds(client, controller.signal) + const second = getAgileFieldIds(client) + controller.abort() + await expect(first).rejects.toThrow('aborted') + response.resolve([ + { id: 'customfield_9', schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } } + ]) + expect(await second).toEqual({ sprint: 'customfield_9' }) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('backs off failed optional discovery so repeated issue reads stay available', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + jiraRequestMock.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce([]) + const client = clientFor('flaky-site') + expect(await getAgileFieldIds(client)).toEqual({}) + expect(await getAgileFieldIds(client)).toEqual({}) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + expect(await getAgileFieldIds(client)).toEqual({}) + expect(jiraRequestMock).toHaveBeenCalledTimes(2) + expect(releaseMock).toHaveBeenCalledTimes(2) + now.mockRestore() + warn.mockRestore() + }) + + it('releases a hung discovery and delays retry without delaying the next issue read', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + jiraRequestMock.mockReturnValueOnce(new Promise(() => {})).mockResolvedValueOnce([]) + const client = clientFor('timeout-site') + const read = getAgileFieldIds(client) + await vi.advanceTimersByTimeAsync(500) + expect(await read).toEqual({}) + expect(jiraRequestMock.mock.calls[0]?.[2].signal.aborted).toBe(true) + expect(releaseMock).toHaveBeenCalledTimes(1) + expect(await getAgileFieldIds(client)).toEqual({}) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000) + expect(await getAgileFieldIds(client)).toEqual({}) + expect(jiraRequestMock).toHaveBeenCalledTimes(2) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('maps parent, estimates and agile custom fields onto the issue', () => { + const issue = mapJiraIssue( + site, + { + id: '1', + key: 'ALP-1', + fields: { + summary: 'Story', + parent: { key: 'ALP-9', fields: { summary: 'Big epic', issuetype: { name: 'Epic' } } }, + timeoriginalestimate: 7200, + timeestimate: 3600, + customfield_1: [{ name: 'Sprint 2', state: 'active' }], + customfield_3: 5 + } + }, + undefined, + { sprint: 'customfield_1', storyPoints: ['customfield_3'] } + ) + expect(issue.parent).toEqual({ key: 'ALP-9', title: 'Big epic', issueTypeName: 'Epic' }) + expect(issue.originalEstimateSeconds).toBe(7200) + expect(issue.remainingEstimateSeconds).toBe(3600) + expect(issue.sprint).toBe('Sprint 2') + expect(issue.storyPoints).toBe(5) + }) +}) diff --git a/src/main/jira/jira-agile-fields.ts b/src/main/jira/jira-agile-fields.ts new file mode 100644 index 00000000000..d697cace0c6 --- /dev/null +++ b/src/main/jira/jira-agile-fields.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import type { JiraAgileFieldIds } from './jira-issue-mapping' +import { asRecord, asString, type JiraRecord } from './jira-record-pages' +import { acquire, release } from './request-queue' +import { settleJiraSummaryRead, withJiraDeadline } from './jira-read-failure' + +const SPRINT_FIELD_TYPE = 'com.pyxis.greenhopper.jira:gh-sprint' +const STORY_POINTS_TYPE = 'com.pyxis.greenhopper.jira:jsw-story-points' +const NUMBER_FIELD_TYPE = 'com.atlassian.jira.plugin.system.customfieldtypes:float' +const cache = new Map>() +const discoveryExpiresAt = new Map() +const FAILED_DISCOVERY_RETRY_MS = 60_000 +const FIELD_CATALOG_TTL_MS = 10 * 60_000 + +export function pickAgileFieldIds(fields: readonly JiraRecord[]): JiraAgileFieldIds { + const ids: JiraAgileFieldIds = {} + for (const field of fields) { + const custom = asString(asRecord(field.schema).custom) + const id = asString(field.id) + if (!id) { + continue + } + if (!ids.sprint && custom === SPRINT_FIELD_TYPE) { + ids.sprint = id + } + if ( + custom === STORY_POINTS_TYPE || + (custom === NUMBER_FIELD_TYPE && /story points?/i.test(asString(field.name))) + ) { + ids.storyPoints ??= [] + if (!ids.storyPoints.includes(id)) { + ids.storyPoints.push(id) + } + } + } + return ids +} + +function clientCacheKey(entry: JiraClientForSite): string { + const credential = createHash('sha256').update(entry.authorization).digest('hex') + return JSON.stringify([ + entry.site.id, + entry.site.siteUrl, + entry.site.authType, + entry.site.accountId, + credential + ]) +} + +export function getAgileFieldIds( + entry: JiraClientForSite, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + return Promise.reject(new Error('Jira field discovery aborted')) + } + const key = clientCacheKey(entry) + const retryAt = discoveryExpiresAt.get(key) + if (retryAt !== undefined && retryAt <= Date.now()) { + cache.delete(key) + discoveryExpiresAt.delete(key) + } + let pending = cache.get(key) + if (!pending) { + // Discovery owns a queue slot; no search may hold one while awaiting it. + pending = withJiraDeadline(undefined, 500, async (requestSignal) => { + await acquire(requestSignal) + try { + const fields = await settleJiraSummaryRead( + jiraRequest(entry, `${apiBasePath(entry.site)}/field`, { + signal: requestSignal + }), + requestSignal + ) + return pickAgileFieldIds(Array.isArray(fields) ? fields : []) + } finally { + release() + } + }) + .then((fields) => { + if (cache.get(key) === pending) { + discoveryExpiresAt.set(key, Date.now() + FIELD_CATALOG_TTL_MS) + } + return fields + }) + .catch((error) => { + console.warn('[jira] field discovery failed:', error) + if (cache.get(key) === pending) { + discoveryExpiresAt.set(key, Date.now() + FAILED_DISCOVERY_RETRY_MS) + } + return {} + }) + if (cache.size >= 32) { + const oldest = cache.keys().next().value + if (oldest) { + cache.delete(oldest) + discoveryExpiresAt.delete(oldest) + } + } + cache.set(key, pending) + } + // A cancelled caller cannot cancel discovery another search is sharing. + return signal ? settleJiraSummaryRead(pending, signal) : pending +} + +export function agileFieldIdList(ids: JiraAgileFieldIds): string[] { + return [...(ids.sprint ? [ids.sprint] : []), ...(ids.storyPoints ?? [])] +} diff --git a/src/main/jira/jira-cloud-planning-composition.test.ts b/src/main/jira/jira-cloud-planning-composition.test.ts new file mode 100644 index 00000000000..d5f9fc94b98 --- /dev/null +++ b/src/main/jira/jira-cloud-planning-composition.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' +import type { JiraRecord } from './jira-record-pages' +import { listIssues, searchIssues } from './jira-issue-search' + +const { getClientsMock, jiraRequestMock } = vi.hoisted(() => ({ + getClientsMock: vi.fn(), + jiraRequestMock: vi.fn() +})) +vi.mock('./client', () => ({ + getClients: (...args: unknown[]) => getClientsMock(...args), + clearToken: vi.fn(), + isAuthError: () => false +})) +vi.mock('./authenticated-request', () => ({ + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) +})) + +type CloudSearchBody = { + jql: string + fields: string[] + maxResults: number + nextPageToken?: string +} +type SearchPageRequest = { + batch: 'first' | 'last' + fields: string[] + limit: number + token: string | undefined +} + +function parseCloudSearchBody(init?: RequestInit): CloudSearchBody { + if (typeof init?.body !== 'string') { + throw new Error('Expected a JSON search request') + } + const body: unknown = JSON.parse(init.body) + if ( + !body || + typeof body !== 'object' || + !('jql' in body) || + typeof body.jql !== 'string' || + !('fields' in body) || + !Array.isArray(body.fields) || + !body.fields.every((field): field is string => typeof field === 'string') || + !('maxResults' in body) || + typeof body.maxResults !== 'number' + ) { + throw new Error('Expected JQL, fields, result limit, and an optional string token') + } + const nextPageToken = 'nextPageToken' in body ? body.nextPageToken : undefined + if (nextPageToken !== undefined && typeof nextPageToken !== 'string') { + throw new Error('Expected a string page token') + } + return { + jql: body.jql, + fields: body.fields, + maxResults: body.maxResults, + ...(nextPageToken !== undefined ? { nextPageToken } : {}) + } +} + +function client(id: string): JiraClientForSite { + return { + site: { + id, + authType: 'cloud', + siteUrl: `https://${id}.atlassian.net`, + email: '', + displayName: id, + accountId: 'viewer' + }, + authorization: 'Basic composition-fixture' + } +} + +function planningIssue( + key: string, + updated: string, + points: number, + laterPage = false +): JiraRecord { + return { + id: key, + key, + fields: { + summary: key, + created: '2020-01-01T00:00:00Z', + updated, + project: { id: key.startsWith('LAST') ? '100' : '0', key: key.split('-')[0] }, + status: { name: 'In Progress', statusCategory: { key: 'indeterminate' } }, + customfield_7: [ + { name: 'Previous sprint', state: 'closed' }, + { name: `${key} sprint`, state: 'active' } + ], + customfield_8: laterPage ? null : points, + customfield_9: laterPage ? points : null, + timeoriginalestimate: 3600, + timeestimate: 0, + parent: { key: 'PLAN-1000', fields: { summary: 'Planning epic' } } + } + } +} + +beforeEach(() => { + getClientsMock.mockReset() + jiraRequestMock.mockReset() +}) + +describe('Cloud search with optional planning field discovery', () => { + it.each([3, 6])( + 'preserves fields across project batches/pages and returns the newest %i issues', + async (limit) => { + const entry = client(`cloud-columns-limit-${limit}`) + getClientsMock.mockReturnValue([entry]) + const requests: SearchPageRequest[] = [] + const projectStarts: number[] = [] + const customFields = ['customfield_7', 'customfield_8', 'customfield_9'] + const firstPage = [planningIssue('FIRST-1', '2026-10-09T12:00:00Z', 5)] + const lastPage = [planningIssue('LAST-1', '2026-10-10T12:00:00Z', 8)] + const firstContinuation = [ + planningIssue('FIRST-2', '2026-10-07T12:00:00Z', 0, true), + planningIssue('FIRST-3', '2020-01-01T00:00:00Z', 2, true) + ] + const lastContinuation = [ + planningIssue('LAST-2', '2026-10-08T12:00:00Z', 0, true), + planningIssue('LAST-3', '2020-01-02T00:00:00Z', 3, true) + ] + jiraRequestMock.mockImplementation( + async (requestEntry: JiraClientForSite, path: string, init?: RequestInit) => { + expect(requestEntry).toBe(entry) + if (path === '/rest/api/3/field') { + return [ + { + id: customFields[0], + name: 'Sprint', + schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } + }, + { + id: customFields[1], + name: 'Story Points', + schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' } + }, + { + id: customFields[2], + name: 'Story point estimate', + schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' } + } + ] + } + if (path.startsWith('/rest/api/3/project/search?')) { + const startAt = Number(new URL(path, entry.site.siteUrl).searchParams.get('startAt')) + projectStarts.push(startAt) + expect([0, 100]).toContain(startAt) + return startAt === 0 + ? { + maxResults: 100, + total: 101, + values: Array.from({ length: 100 }, (_, index) => ({ id: String(index) })) + } + : { maxResults: 100, total: 101, values: [{ id: '100' }] } + } + expect(path).toBe('/rest/api/3/search/jql') + expect(init?.method).toBe('POST') + expect(init?.signal).toBeInstanceOf(AbortSignal) + const body = parseCloudSearchBody(init) + expect(body.jql).toContain('project in (') + expect(body.jql).toContain('statusCategory != Done') + expect(body.jql).not.toMatch(/resolution|updated\s*[><=]/) + expect(body.fields).toEqual(expect.arrayContaining(customFields)) + expect(body.fields).toEqual( + expect.arrayContaining(['parent', 'timeoriginalestimate', 'timeestimate']) + ) + expect(body.fields).not.toContain('description') + const batch = body.jql.includes('"100"') ? 'last' : 'first' + if (batch === 'first') { + expect(body.jql).toContain('"0"') + expect(body.jql).toContain('"99"') + expect(body.jql).not.toContain('"100"') + } else { + expect(body.jql).toContain('project in ("100")') + } + requests.push({ + batch, + fields: body.fields, + limit: body.maxResults, + token: body.nextPageToken + }) + if (!body.nextPageToken) { + return { + issues: batch === 'first' ? firstPage : lastPage, + nextPageToken: `${batch}-next` + } + } + expect(body.nextPageToken).toBe(`${batch}-next`) + return { + issues: batch === 'first' ? firstContinuation : lastContinuation, + isLast: true + } + } + ) + + const results = await listIssues('all', limit, entry.site.id, true) + + expect(results).toHaveLength(limit) + expect(results.slice(0, 3).map((item) => item.key)).toEqual(['LAST-1', 'FIRST-1', 'LAST-2']) + expect(projectStarts).toEqual([0, 100]) + expect( + requests.map(({ batch, limit: pageLimit, token }) => ({ batch, limit: pageLimit, token })) + ).toEqual([ + { batch: 'first', token: undefined, limit }, + { batch: 'first', token: 'first-next', limit: limit - 1 }, + { batch: 'last', token: undefined, limit }, + { batch: 'last', token: 'last-next', limit: limit - 1 } + ]) + expect( + requests.every( + (request) => JSON.stringify(request.fields) === JSON.stringify(requests[0]?.fields) + ) + ).toBe(true) + expect( + jiraRequestMock.mock.calls.filter((call) => call[1] === '/rest/api/3/field') + ).toHaveLength(1) + expect(jiraRequestMock).toHaveBeenCalledTimes(7) + expect(results.find((item) => item.key === 'LAST-2')).toMatchObject({ + siteId: entry.site.id, + sprint: 'LAST-2 sprint', + storyPoints: 0, + originalEstimateSeconds: 3600, + remainingEstimateSeconds: 0, + parent: { key: 'PLAN-1000', title: 'Planning epic' } + }) + if (limit === 6) { + const expectedPoints: Record = { + 'FIRST-1': 5, + 'FIRST-2': 0, + 'FIRST-3': 2, + 'LAST-1': 8, + 'LAST-2': 0, + 'LAST-3': 3 + } + for (const item of results) { + expect(item.sprint).toBe(`${item.key} sprint`) + expect(item.storyPoints).toBe(expectedPoints[item.key]) + } + } + } + ) + + it.each(['list', 'search'] as const)( + 'rejects disconnected strict %s reads before field discovery or any other request', + async (kind) => { + getClientsMock.mockReturnValue([]) + const request = + kind === 'list' + ? listIssues('all', 3, 'strict-disconnected-cloud', true) + : searchIssues('project = PLAN', 3, 'strict-disconnected-cloud', undefined, true) + await expect(request).rejects.toThrow('one connected site ID') + expect(getClientsMock).toHaveBeenCalledWith('strict-disconnected-cloud') + expect(jiraRequestMock).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/jira/jira-issue-mapping.ts b/src/main/jira/jira-issue-mapping.ts index cc7c9357eaf..7939200b89f 100644 --- a/src/main/jira/jira-issue-mapping.ts +++ b/src/main/jira/jira-issue-mapping.ts @@ -14,6 +14,7 @@ import { asRecord, asString, asStringArray, + asFiniteNumber, type JiraPagedResponse, type JiraRecord } from './jira-record-pages' @@ -29,7 +30,10 @@ export const ISSUE_FIELDS = [ 'priority', 'labels', 'created', - 'updated' + 'updated', + 'parent', + 'timeoriginalestimate', + 'timeestimate' ] // Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. @@ -182,10 +186,53 @@ export function toBodyText(site: JiraSite, text: string): unknown { return site.authType === 'server' ? text : textToAdf(text) } +/** Site-specific custom field ids for agile data; discovered per site, see jira-agile-fields.ts. */ +export type JiraAgileFieldIds = { + sprint?: string + storyPoints?: string[] +} + +function mapParent(value: unknown): JiraIssue['parent'] { + const parent = asRecord(value) + const key = asString(parent.key) + if (!key) { + return undefined + } + const fields = asRecord(parent.fields) + return { + key, + title: asString(fields.summary, key), + issueTypeName: asString(asRecord(fields.issuetype).name) || undefined + } +} + +// Cloud returns sprint objects; Server/DC returns "…Sprint@…[id=1,…,name=Sprint 3,…]" strings. +function sprintName(value: unknown): string | undefined { + if (typeof value === 'string') { + return /name=(.*?)(?:,(?:startDate|endDate|completeDate|sequence|goal)=|\]$)/.exec(value)?.[1] + } + return asString(asRecord(value).name) || undefined +} + +function sprintState(value: unknown): string | undefined { + if (typeof value === 'string') { + return /state=([^,\]]+)/.exec(value)?.[1] + } + return asString(asRecord(value).state) || undefined +} + +// Why: Jira orders an issue's sprints by id, not state, so the last one may be closed. +export function mapSprint(value: unknown): string | undefined { + const sprints = Array.isArray(value) ? value : value ? [value] : [] + const active = sprints.find((sprint) => sprintState(sprint)?.toLowerCase() === 'active') + return sprintName(active ?? sprints.at(-1)) +} + export function mapJiraIssue( site: JiraSite, raw: JiraRecord, - adfOptions?: AdfToMarkdownOptions + adfOptions?: AdfToMarkdownOptions, + agileFields: JiraAgileFieldIds = {} ): JiraIssue { const fields = asRecord(raw.fields) const key = asString(raw.key) @@ -204,6 +251,14 @@ export function mapJiraIssue( assignee: mapUser(fields.assignee), reporter: mapUser(fields.reporter), priority: mapPriority(fields.priority), + parent: mapParent(fields.parent), + sprint: agileFields.sprint ? mapSprint(fields[agileFields.sprint]) : undefined, + storyPoints: + agileFields.storyPoints + ?.map((id) => asFiniteNumber(fields[id])) + .find((value) => value !== null) ?? undefined, + originalEstimateSeconds: asFiniteNumber(fields.timeoriginalestimate) ?? undefined, + remainingEstimateSeconds: asFiniteNumber(fields.timeestimate) ?? undefined, createdAt: asString(fields.created, new Date().toISOString()), updatedAt: asString(fields.updated, new Date().toISOString()) } diff --git a/src/main/jira/jira-issue-read.ts b/src/main/jira/jira-issue-read.ts index 8ddb48141ff..bc8f184a4b1 100644 --- a/src/main/jira/jira-issue-read.ts +++ b/src/main/jira/jira-issue-read.ts @@ -3,7 +3,13 @@ import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' -import { ISSUE_DETAIL_FIELDS, ISSUE_SUMMARY_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import { agileFieldIdList, getAgileFieldIds } from './jira-agile-fields' +import { + ISSUE_DETAIL_FIELDS, + ISSUE_SUMMARY_FIELDS, + mapJiraIssue, + type JiraAgileFieldIds +} from './jira-issue-mapping' import { collectIssueMediaRequest, flushMediaResolutionWarn, @@ -32,11 +38,13 @@ export async function getIssue( let mediaRequest: MediaRequest | undefined let issue: JiraRecord | undefined let held = false + let agileFields: JiraAgileFieldIds = {} try { + agileFields = await getAgileFieldIds(entry) await acquire() held = true const params = new URLSearchParams({ - fields: ISSUE_DETAIL_FIELDS.join(','), + fields: [...ISSUE_DETAIL_FIELDS, ...agileFieldIdList(agileFields)].join(','), expand: 'renderedFields' }) issue = await jiraRequest( @@ -70,14 +78,14 @@ export async function getIssue( continue } const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = mapJiraIssue(entry.site, issue, prepared?.options) + const mapped = mapJiraIssue(entry.site, issue, prepared?.options, agileFields) if (prepared) { flushMediaResolutionWarn(entry, prepared) } return mapped } catch (error) { console.warn('[jira] getIssue media load failed:', error) - return mapJiraIssue(entry.site, issue) + return mapJiraIssue(entry.site, issue, undefined, agileFields) } } return null diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts index 461136bf82b..d817e2abaaf 100644 --- a/src/main/jira/jira-issue-search.ts +++ b/src/main/jira/jira-issue-search.ts @@ -4,7 +4,8 @@ import { buildJiraPresetJql } from '../../shared/jira-preset-jql' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' -import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import { ISSUE_LIST_FIELDS, mapJiraIssue, type JiraAgileFieldIds } from './jira-issue-mapping' +import { agileFieldIdList, getAgileFieldIds } from './jira-agile-fields' import type { JiraSearchResponse } from './jira-record-pages' import { readProjectsForClient } from './jira-project-queries' import { @@ -31,14 +32,15 @@ async function readIssuesForClient( entry: JiraClientForSite, query: JiraIssueQuery, limit: number, + agileFields: JiraAgileFieldIds, signal: AbortSignal ): Promise { if ('jql' in query) { - return searchIssuesForClient(entry, query.jql, limit, signal) + return searchIssuesForClient(entry, query.jql, limit, agileFields, signal) } const jql = buildJiraPresetJql(query.filter) if (query.filter !== 'all' || entry.site.authType === 'server') { - return searchIssuesForClient(entry, jql, limit, signal) + return searchIssuesForClient(entry, jql, limit, agileFields, signal) } const projects = await readProjectsForClient(entry, signal, true) const projectIds = [...new Set(projects.map((project) => project.id || project.key))] @@ -54,6 +56,7 @@ async function readIssuesForClient( entry, `project in (${ids.join(', ')}) AND ${jql}`, limit, + agileFields, signal, true )) @@ -66,6 +69,7 @@ async function searchIssuesForClient( entry: JiraClientForSite, jql: string, limit: number, + agileFields: JiraAgileFieldIds, signal?: AbortSignal, collectPages = false ): Promise { @@ -83,7 +87,7 @@ async function searchIssuesForClient( body: JSON.stringify({ jql, maxResults: limit - issues.length, - fields: ISSUE_LIST_FIELDS, + fields: [...ISSUE_LIST_FIELDS, ...agileFieldIdList(agileFields)], ...(nextPageToken ? { nextPageToken } : {}) }), signal @@ -91,7 +95,11 @@ async function searchIssuesForClient( if (collectPages && !Array.isArray(result.issues)) { throw new Error('Jira returned an invalid issue page.') } - issues.push(...(result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue))) + issues.push( + ...(result.issues ?? []).map((issue) => + mapJiraIssue(entry.site, issue, undefined, agileFields) + ) + ) if (!collectPages || issues.length >= limit || result.isLast === true) { return issues.slice(0, limit) } @@ -147,10 +155,12 @@ async function runIssueSearch( const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => Promise.all( entries.map(async (entry, index) => { + // Discovery owns a slot, so finish it before acquiring the search slot. + const agileFields = await getAgileFieldIds(entry, requestSignal) // Why: queueing on an abandoned search would keep occupying the shared Jira pool. await acquire(requestSignal) try { - return await readIssuesForClient(entry, query, safeLimit, requestSignal) + return await readIssuesForClient(entry, query, safeLimit, agileFields, requestSignal) } catch (error) { if (requestSignal.aborted) { // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. diff --git a/src/main/jira/jira-preset-filters.test.ts b/src/main/jira/jira-preset-filters.test.ts index d5658480f51..648c19cee9d 100644 --- a/src/main/jira/jira-preset-filters.test.ts +++ b/src/main/jira/jira-preset-filters.test.ts @@ -14,6 +14,10 @@ vi.mock('./authenticated-request', () => ({ jiraRequest, JiraApiError: class extends Error {} })) +vi.mock('./jira-agile-fields', () => ({ + getAgileFieldIds: async () => ({}), + agileFieldIdList: () => [] +})) vi.mock('./request-queue', () => ({ acquire: vi.fn(), release: vi.fn() })) function site(id: string, authType: 'cloud' | 'server' = 'cloud'): JiraClientForSite { diff --git a/src/main/jira/jira-search-abort.test.ts b/src/main/jira/jira-search-abort.test.ts index 09126ea1c41..52801aaf3a0 100644 --- a/src/main/jira/jira-search-abort.test.ts +++ b/src/main/jira/jira-search-abort.test.ts @@ -10,6 +10,11 @@ const { acquireMock, clearTokenMock, getClientsMock, isAuthErrorMock, jiraReques jiraRequestMock: vi.fn() })) +vi.mock('./jira-agile-fields', () => ({ + getAgileFieldIds: async () => ({}), + agileFieldIdList: () => [] +})) + vi.mock('./request-queue', () => ({ acquire: (...args: unknown[]) => acquireMock(...args), release: vi.fn() diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0a1e26ce063..840733db0e4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -132,6 +132,9 @@ --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); --color-status-warning: var(--status-warning); + --color-jira-priority-high: var(--jira-priority-high); + --color-jira-priority-medium: var(--jira-priority-medium); + --color-jira-priority-low: var(--jira-priority-low); --color-status-warning-background: var(--status-warning-background); --color-status-warning-border: var(--status-warning-border); --color-callout-note: var(--callout-note); @@ -406,6 +409,9 @@ --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); /* Needs-attention states use yellow, separate from errors and working states. */ --status-warning: #ca8a04; + --jira-priority-high: var(--destructive); + --jira-priority-medium: var(--status-warning); + --jira-priority-low: var(--callout-note); --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); /* GitHub's own hues for the two callout kinds no status token covers. */ @@ -432,6 +438,9 @@ --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); --status-warning: #eab308; + --jira-priority-high: var(--destructive); + --jira-priority-medium: var(--status-warning); + --jira-priority-low: var(--callout-note); --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); --callout-note: #4493f8; diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index feaca1bd680..20e889b41b5 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,7 +1,8 @@ +import type { JiraListColumnId } from './jira-list-columns' import type { JiraIssue, JiraPriority } from '../../../shared/jira-types' import { compareNumericLocaleText } from '@/lib/locale-text-collators' -export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' +export type JiraIssueSortColumn = JiraListColumnId export type JiraIssueSortDirection = 'asc' | 'desc' export type JiraPrioritiesBySite = ReadonlyMap @@ -86,6 +87,16 @@ export function sortJiraIssues( comparison = userA.localeCompare(userB) } else if (orderBy === 'updated') { comparison = numericKeys.get(a)! - numericKeys.get(b)! + } else if (orderBy === 'parent') { + comparison = compareNumericLocaleText(a.parent?.title ?? '', b.parent?.title ?? '') + } else if (orderBy === 'sprint') { + comparison = compareNumericLocaleText(a.sprint ?? '', b.sprint ?? '') + } else if (orderBy === 'storyPoints') { + comparison = (a.storyPoints ?? -1) - (b.storyPoints ?? -1) + } else if (orderBy === 'originalEstimate') { + comparison = (a.originalEstimateSeconds ?? -1) - (b.originalEstimateSeconds ?? -1) + } else if (orderBy === 'remainingEstimate') { + comparison = (a.remainingEstimateSeconds ?? -1) - (b.remainingEstimateSeconds ?? -1) } return orderDirection === 'asc' ? comparison : -comparison }) diff --git a/src/renderer/src/components/jira-list-columns-menu.tsx b/src/renderer/src/components/jira-list-columns-menu.tsx new file mode 100644 index 00000000000..83c54480e40 --- /dev/null +++ b/src/renderer/src/components/jira-list-columns-menu.tsx @@ -0,0 +1,53 @@ +import { Columns3 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { + JIRA_LIST_COLUMNS, + getJiraListColumnLabel, + type JiraListColumnId +} from './jira-list-columns' + +export function JiraListColumnsMenu({ + ids, + onToggle +}: { + ids: ReadonlySet + onToggle: (column: JiraListColumnId) => void +}): React.JSX.Element { + const label = translate('auto.components.TaskPage.jiraColumnsPicker', 'Columns') + return ( + + + + + + + + {label} + + + {label} + {JIRA_LIST_COLUMNS.filter((column) => !column.locked).map((column) => ( + event.preventDefault()} + onCheckedChange={() => onToggle(column.id)} + > + {getJiraListColumnLabel(column.id)} + + ))} + + + ) +} diff --git a/src/renderer/src/components/jira-list-columns.test.ts b/src/renderer/src/components/jira-list-columns.test.ts new file mode 100644 index 00000000000..2228aee956a --- /dev/null +++ b/src/renderer/src/components/jira-list-columns.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + JIRA_LIST_COLUMNS, + defaultJiraListColumnIds, + fallbackJiraSortColumn, + formatJiraEstimate, + jiraListGridStyle, + jiraListGridTemplate, + loadJiraListColumnIds, + resolveJiraListColumnIds, + saveJiraListColumnIds, + visibleJiraListColumns +} from './jira-list-columns' + +afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +describe('Jira list columns', () => { + it('defaults to the previous columns with planning fields hidden', () => { + expect([...defaultJiraListColumnIds()]).toEqual([ + 'key', + 'title', + 'status', + 'priority', + 'assignee', + 'updated' + ]) + }) + it('drops invalid saved ids and retains the issue identity', () => { + expect( + [...resolveJiraListColumnIds(['sprint', 'storyPoints', 'bogus', null, {}])].sort() + ).toEqual(['key', 'sprint', 'storyPoints', 'title']) + expect([...resolveJiraListColumnIds('nope')]).toEqual([...defaultJiraListColumnIds()]) + }) + it('restores per-device choices and handles corrupt or unavailable storage', () => { + saveJiraListColumnIds(new Set(['key', 'title', 'sprint'])) + expect([...loadJiraListColumnIds()].sort()).toEqual(['key', 'sprint', 'title']) + localStorage.setItem('orca.jira.list-columns.v1', '{') + expect(loadJiraListColumnIds()).toEqual(defaultJiraListColumnIds()) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('full') + }) + expect(() => saveJiraListColumnIds(new Set(['key', 'title']))).not.toThrow() + }) + it('keeps a title floor at each panel tier when all columns are selected', () => { + const columns = visibleJiraListColumns(new Set(JIRA_LIST_COLUMNS.map((column) => column.id))) + for (const tier of [0, 1, 2] as const) { + expect(jiraListGridTemplate(columns, tier)).toContain('minmax(180px,1fr)') + expect(jiraListGridTemplate(columns, tier).endsWith('104px')).toBe(true) + } + const style = jiraListGridStyle(columns) + expect(Number.parseInt(style['--jira-min-md'])).toBeGreaterThan(780) + expect(Number.parseInt(style['--jira-min-lg'])).toBeGreaterThan(1050) + expect(jiraListGridTemplate(columns, 0)).not.toContain('136px') + }) + it('chooses a visible fallback when the active sort column is hidden', () => { + expect(fallbackJiraSortColumn(defaultJiraListColumnIds())).toBe('updated') + expect(fallbackJiraSortColumn(new Set(['key', 'title', 'sprint']))).toBe('key') + }) + it('formats exact hours without assuming the Jira working day length', () => { + expect(formatJiraEstimate(undefined)).toBe('–') + expect(formatJiraEstimate(0)).toBe('0m') + expect(formatJiraEstimate(45 * 60)).toBe('45m') + expect(formatJiraEstimate(10 * 3600)).toBe('10h') + expect(formatJiraEstimate(40 * 3600 + 30 * 60)).toBe('40h 30m') + }) +}) diff --git a/src/renderer/src/components/jira-list-columns.ts b/src/renderer/src/components/jira-list-columns.ts new file mode 100644 index 00000000000..8c6c7844f6b --- /dev/null +++ b/src/renderer/src/components/jira-list-columns.ts @@ -0,0 +1,173 @@ +import type { CSSProperties } from 'react' +import { translate } from '@/i18n/i18n' + +export type JiraListColumnId = + | 'key' + | 'title' + | 'status' + | 'priority' + | 'assignee' + | 'parent' + | 'sprint' + | 'storyPoints' + | 'originalEstimate' + | 'remainingEstimate' + | 'updated' + +export type JiraListColumn = { + id: JiraListColumnId + widths: readonly [number, number, number] + /** Always shown; not offered in the column picker. */ + locked?: boolean + defaultVisible: boolean +} + +export const JIRA_LIST_COLUMNS: readonly JiraListColumn[] = [ + { id: 'key', widths: [90, 96, 104], locked: true, defaultVisible: true }, + { id: 'title', widths: [180, 180, 180], locked: true, defaultVisible: true }, + { id: 'status', widths: [128, 132, 144], defaultVisible: true }, + { id: 'priority', widths: [92, 120, 132], defaultVisible: true }, + { id: 'assignee', widths: [0, 136, 160], defaultVisible: true }, + { id: 'parent', widths: [150, 150, 150], defaultVisible: false }, + { id: 'sprint', widths: [110, 110, 110], defaultVisible: false }, + { id: 'storyPoints', widths: [60, 60, 60], defaultVisible: false }, + { id: 'originalEstimate', widths: [88, 88, 88], defaultVisible: false }, + { id: 'remainingEstimate', widths: [88, 88, 88], defaultVisible: false }, + { id: 'updated', widths: [80, 96, 128], defaultVisible: true } +] + +// Device-local, like Linear display preferences, rather than synced through ui.set. +const STORAGE_KEY = 'orca.jira.list-columns.v1' + +export function getJiraListColumnLabel(id: JiraListColumnId): string { + switch (id) { + case 'key': + return translate('auto.components.TaskPage.37e7ee311e', 'Key') + case 'title': + return translate('auto.components.TaskPage.b1eaa18ace', 'Issue') + case 'status': + return translate('auto.components.TaskPage.154b0fa623', 'Status') + case 'priority': + return translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') + case 'assignee': + return translate('auto.components.TaskPage.d2a876ca53', 'Assignee') + case 'parent': + return translate('auto.components.TaskPage.jiraColumnParent', 'Parent') + case 'sprint': + return translate('auto.components.TaskPage.jiraColumnSprint', 'Sprint') + case 'storyPoints': + return translate('auto.components.TaskPage.jiraColumnStoryPoints', 'Points') + case 'originalEstimate': + return translate('auto.components.TaskPage.jiraColumnOriginalEstimate', 'Estimate') + case 'remainingEstimate': + return translate('auto.components.TaskPage.jiraColumnRemainingEstimate', 'Remaining') + case 'updated': + return translate('auto.components.TaskPage.f362667d55', 'Updated') + } +} + +export function defaultJiraListColumnIds(): Set { + return new Set(JIRA_LIST_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.id)) +} + +export function resolveJiraListColumnIds(raw: unknown): Set { + if (!Array.isArray(raw)) { + return defaultJiraListColumnIds() + } + const ids = new Set( + raw.filter( + (id): id is JiraListColumnId => + typeof id === 'string' && JIRA_LIST_COLUMNS.some((column) => column.id === id) + ) + ) + for (const column of JIRA_LIST_COLUMNS) { + if (column.locked) { + ids.add(column.id) + } + } + return ids +} + +export function loadJiraListColumnIds(): Set { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return resolveJiraListColumnIds(raw ? JSON.parse(raw) : undefined) + } catch { + return defaultJiraListColumnIds() + } +} + +export function saveJiraListColumnIds(ids: ReadonlySet): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids])) + } catch { + // The live view stays usable when browser storage is unavailable or full. + } +} + +export function visibleJiraListColumns(ids: ReadonlySet): JiraListColumn[] { + return JIRA_LIST_COLUMNS.filter((column) => ids.has(column.id)) +} + +export function jiraListGridTemplate( + columns: readonly JiraListColumn[], + tier: 0 | 1 | 2 = 1 +): string { + return [ + ...columns + .filter((column) => column.widths[tier] > 0) + .map((column) => (column.id === 'title' ? 'minmax(180px,1fr)' : `${column.widths[tier]}px`)), + '104px' + ].join(' ') +} + +type JiraGridStyle = CSSProperties & { + '--jira-cols-md': string + '--jira-cols-lg': string + '--jira-cols-xl': string + '--jira-min-md': string + '--jira-min-lg': string + '--jira-min-xl': string +} + +function jiraListMinimumWidth(columns: readonly JiraListColumn[], tier: 0 | 1 | 2): string { + const shown = columns.filter((column) => column.widths[tier] > 0) + const tracks = shown.reduce((sum, column) => sum + column.widths[tier], 104) + return `${tracks + shown.length * 12 + 24}px` +} + +export function jiraListGridStyle(columns: readonly JiraListColumn[]): JiraGridStyle { + return { + '--jira-cols-md': jiraListGridTemplate(columns, 0), + '--jira-cols-lg': jiraListGridTemplate(columns, 1), + '--jira-cols-xl': jiraListGridTemplate(columns, 2), + '--jira-min-md': jiraListMinimumWidth(columns, 0), + '--jira-min-lg': jiraListMinimumWidth(columns, 1), + '--jira-min-xl': jiraListMinimumWidth(columns, 2) + } +} + +export function jiraListColumnVisibility(column: JiraListColumnId): string | undefined { + return column === 'assignee' ? '@max-5xl:!hidden' : undefined +} + +export function fallbackJiraSortColumn(ids: ReadonlySet): JiraListColumnId { + return ids.has('updated') ? 'updated' : 'key' +} + +// Hours do not depend on the site's configurable working day/week length. +export function formatJiraEstimate(seconds: number | undefined): string { + if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) { + return '–' + } + if (seconds === 0) { + return '0m' + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : ''].filter(Boolean).join(' ') || '<1m' +} + +export function formatJiraStoryPoints(points: number | undefined): string { + return points === undefined ? '–' : String(points) +} diff --git a/src/renderer/src/components/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page-jira-grouping.test.ts index 8d9386c6050..e03154149e4 100644 --- a/src/renderer/src/components/task-page-jira-grouping.test.ts +++ b/src/renderer/src/components/task-page-jira-grouping.test.ts @@ -1,3 +1,4 @@ +import { defaultJiraListColumnIds, visibleJiraListColumns } from './jira-list-columns' // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' @@ -174,6 +175,7 @@ describe('Jira issue status grouping', () => { TooltipProvider, null, React.createElement(TaskPageJiraIssueList, { + columns: visibleJiraListColumns(defaultJiraListColumnIds()), formatUpdatedAt: () => 'today', getStatusTone: () => 'border-border', issues: [ diff --git a/src/renderer/src/components/task-page-jira-issue-cells.tsx b/src/renderer/src/components/task-page-jira-issue-cells.tsx new file mode 100644 index 00000000000..c8443926ed2 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-issue-cells.tsx @@ -0,0 +1,148 @@ +import React from 'react' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { JiraIssue } from '../../../shared/jira-types' +import { + formatJiraEstimate, + formatJiraStoryPoints, + type JiraListColumnId +} from './jira-list-columns' +import { getJiraPriorityTone } from './task-page-jira-status-tone' + +const MUTED_CELL = 'block truncate text-[12px] text-muted-foreground' + +export function noPriorityLabel(): string { + return translate('auto.components.TaskPage.713179dfdc', 'No priority') +} + +export function unassignedLabel(): string { + return translate('auto.components.TaskPage.42a9160321', 'Unassigned') +} + +export function JiraPriorityText({ + issue, + className +}: { + issue: JiraIssue + className?: string +}): React.JSX.Element { + return ( + + {issue.priority?.name ?? noPriorityLabel()} + + ) +} + +function ParentCell({ issue }: { issue: JiraIssue }): React.JSX.Element { + if (!issue.parent) { + return – + } + return ( + + + + + {issue.parent.key} + + {issue.parent.title} + + + + {issue.parent.issueTypeName ? `${issue.parent.issueTypeName}: ` : ''} + {issue.parent.title} + + + ) +} + +function AssigneeCell({ issue }: { issue: JiraIssue }): React.JSX.Element { + return ( +
+ {issue.assignee?.avatarUrl ? ( + {issue.assignee.displayName} + ) : ( + + {issue.assignee?.displayName?.slice(0, 1) ?? '-'} + + )} + {issue.assignee?.displayName ?? unassignedLabel()} +
+ ) +} + +/** Desktop cell for every column except `key` and `title`, which the row renders itself. */ +export function JiraIssueCell({ + column, + issue, + formatUpdatedAt, + getStatusTone +}: { + column: JiraListColumnId + issue: JiraIssue + formatUpdatedAt: (updatedAt: string) => string + getStatusTone: (categoryKey: string) => string +}): React.JSX.Element | null { + switch (column) { + case 'status': + return ( +
+ + {issue.status.name} + +
+ ) + case 'priority': + return + case 'assignee': + return + case 'parent': + return + case 'sprint': + return {issue.sprint ?? '–'} + case 'storyPoints': + return ( + + {formatJiraStoryPoints(issue.storyPoints)} + + ) + case 'originalEstimate': + return ( + + {formatJiraEstimate(issue.originalEstimateSeconds)} + + ) + case 'remainingEstimate': + return ( + + {formatJiraEstimate(issue.remainingEstimateSeconds)} + + ) + case 'updated': + return ( + + +
+ {formatUpdatedAt(issue.updatedAt)} +
+
+ + {new Date(issue.updatedAt).toLocaleString()} + +
+ ) + case 'key': + case 'title': + return null + } +} diff --git a/src/renderer/src/components/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page-jira-issue-list.tsx index 7f97e22ab06..f66c3b3c05e 100644 --- a/src/renderer/src/components/task-page-jira-issue-list.tsx +++ b/src/renderer/src/components/task-page-jira-issue-list.tsx @@ -9,6 +9,8 @@ import { cn } from '@/lib/utils' import { JiraIssueRowCopyMenu } from './JiraIssueRowCopyMenu' import { JiraLinkedWorkspaceAction } from './JiraLinkedWorkspaceAction' import type { Worktree } from '../../../shared/worktree/types' +import { jiraListColumnVisibility, type JiraListColumn } from './jira-list-columns' +import { JiraIssueCell, JiraPriorityText, unassignedLabel } from './task-page-jira-issue-cells' import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/jira-types' export type TaskPageJiraIssueSection = { @@ -20,6 +22,7 @@ export type TaskPageJiraIssueSection = { type TaskPageJiraIssueListProps = { getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[] onOpenWorkspace?: (workspace: Worktree) => void + columns: readonly JiraListColumn[] formatUpdatedAt: (updatedAt: string) => string getStatusTone: (categoryKey: string) => string issues: JiraIssue[] @@ -92,6 +95,7 @@ function isSelectedIssue(issue: JiraIssue, selectedIssue: JiraIssue | null): boo function JiraIssueRow({ getIssueWorkspaces, onOpenWorkspace, + columns, formatUpdatedAt, getStatusTone, issue, @@ -100,8 +104,6 @@ function JiraIssueRow({ selected, showSiteContext }: { - getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[] - onOpenWorkspace?: (workspace: Worktree) => void formatUpdatedAt: (updatedAt: string) => string getStatusTone: (categoryKey: string) => string issue: JiraIssue @@ -109,6 +111,9 @@ function JiraIssueRow({ onStartWorkspace: (issue: JiraIssue) => void selected: boolean showSiteContext: boolean + columns: readonly JiraListColumn[] + getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[] + onOpenWorkspace?: (workspace: Worktree) => void }): React.JSX.Element { const workspaces = getIssueWorkspaces?.(issue) ?? [] const labels = issue.labels.slice(0, 3) @@ -137,7 +142,7 @@ function JiraIssueRow({ } }} className={cn( - 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring @3xl:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px_104px] @5xl:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_104px] @7xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_104px]', + 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring @3xl:grid-cols-(--jira-cols-md) @3xl:min-w-(--jira-min-md) @5xl:grid-cols-(--jira-cols-lg) @5xl:min-w-(--jira-min-lg) @7xl:grid-cols-(--jira-cols-xl) @7xl:min-w-(--jira-min-xl)', selected && 'bg-accent' )} > @@ -163,13 +168,9 @@ function JiraIssueRow({ > {issue.status.name} - - {issue.priority?.name ?? - translate('auto.components.TaskPage.713179dfdc', 'No priority')} - + - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} + {issue.assignee?.displayName ?? unassignedLabel()}
@@ -192,49 +193,21 @@ function JiraIssueRow({
-
- - {issue.status.name} - -
- - - {issue.priority?.name ?? translate('auto.components.TaskPage.713179dfdc', 'No priority')} - - -
- {issue.assignee?.avatarUrl ? ( - {issue.assignee.displayName} - ) : ( - - {issue.assignee?.displayName?.slice(0, 1) ?? '-'} - - )} - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
- - - -
- {formatUpdatedAt(issue.updatedAt)} + {columns + .filter((column) => !column.locked) + .map((column) => ( +
+
- - - {new Date(issue.updatedAt).toLocaleString()} - - + ))}
@@ -300,6 +273,7 @@ function JiraIssueRow({ export function TaskPageJiraIssueList({ getIssueWorkspaces, onOpenWorkspace, + columns, formatUpdatedAt, getStatusTone, issues, @@ -358,9 +332,10 @@ export function TaskPageJiraIssueList({ {section.issues.map((issue) => ( { return render( - + ) } diff --git a/src/renderer/src/components/task-page-jira-sort-controls.tsx b/src/renderer/src/components/task-page-jira-sort-controls.tsx index 4e593c1e104..685ecbd9e82 100644 --- a/src/renderer/src/components/task-page-jira-sort-controls.tsx +++ b/src/renderer/src/components/task-page-jira-sort-controls.tsx @@ -11,41 +11,30 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { + getJiraListColumnLabel, + jiraListColumnVisibility, + type JiraListColumn +} from './jira-list-columns' import type { JiraIssueSortColumn, JiraIssueSortDirection } from './jira-issue-sorter' -type JiraSortColumn = { - id: JiraIssueSortColumn - label: string - className?: string -} - type TaskPageJiraSortControlsProps = { + columns: readonly JiraListColumn[] direction: JiraIssueSortDirection onSort: (column: JiraIssueSortColumn) => void orderBy: JiraIssueSortColumn } -function getJiraSortColumns(): JiraSortColumn[] { - return [ - { id: 'key', label: translate('auto.components.TaskPage.37e7ee311e', 'Key') }, - { id: 'title', label: translate('auto.components.TaskPage.b1eaa18ace', 'Issue') }, - { id: 'status', label: translate('auto.components.TaskPage.154b0fa623', 'Status') }, - { id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') }, - { - id: 'assignee', - label: translate('auto.components.TaskPage.d2a876ca53', 'Assignee'), - className: '@max-5xl:!hidden' - }, - { id: 'updated', label: translate('auto.components.TaskPage.f362667d55', 'Updated') } - ] -} - export function TaskPageJiraSortControls({ + columns: visibleColumns, direction, onSort, orderBy }: TaskPageJiraSortControlsProps): React.JSX.Element { - const columns = getJiraSortColumns() + const columns = visibleColumns.map((column) => ({ + id: column.id, + label: getJiraListColumnLabel(column.id) + })) const directionLabel = direction === 'asc' ? translate('auto.components.TaskPage.jiraSortAscending', 'ascending') @@ -63,7 +52,7 @@ export function TaskPageJiraSortControls({ return ( <> -
+
{columns.map((column) => (
) : ( -
+
{translate('auto.components.TaskPage.63b2abd3aa', 'Jira issues')}
-
- {displayedJiraIssues.length} {translate('auto.components.TaskPage.b7bae28b6a', 'shown')} +
+ + {displayedJiraIssues.length}{' '} + {translate('auto.components.TaskPage.b7bae28b6a', 'shown')} + +
- -
+ + {jiraStatus.credentialError ? (
{jiraStatus.credentialError} @@ -166,6 +179,7 @@ export function TaskPageJiraContent({ ) } }} + columns={jiraColumns} formatUpdatedAt={formatRelativeTime} getStatusTone={getJiraStatusTone} issues={sortedJiraIssues} diff --git a/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx b/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx new file mode 100644 index 00000000000..39c155aafd7 --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { JiraPriority } from '../../../shared/jira-types' +import { useTaskPageJiraListState } from './use-task-page-jira-list-state' + +const transport = vi.hoisted(() => ({ priorities: vi.fn() })) +vi.mock('@/runtime/runtime-jira-client', () => ({ + jiraListPrioritiesForTarget: transport.priorities, + jiraListPriorities: transport.priorities +})) + +function fixture( + accountHostId: ExecutionHostId = 'runtime:account', + sourceContext: TaskSourceContext | null = null +): TaskPageLinearViewStateModel { + const model = { + settings: { activeRuntimeEnvironmentId: 'focus' }, + accountBackedTaskSourceHostId: accountHostId, + jiraConnected: true, + selectedJiraSiteId: 'site-1', + taskSource: 'jira', + jiraTaskSourceContext: sourceContext + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated hook reads only the listed pipeline fields. + return model as unknown as TaskPageLinearViewStateModel +} + +async function selectPriority(result: { current: ReturnType }) { + await act(async () => result.current.setJiraOrderBy('priority')) + await waitFor(() => expect(transport.priorities).toHaveBeenCalledTimes(1)) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + transport.priorities.mockResolvedValue([]) +}) +afterEach(cleanup) + +describe('Jira priority list source owner', () => { + it('uses the captured account owner when the focused server differs', async () => { + const model = fixture() + const { result } = renderHook(() => useTaskPageJiraListState(model)) + await selectPriority(result) + expect(transport.priorities).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'account' }, + 'site-1' + ) + }) + + it('uses an explicit source before the captured account or focused server', async () => { + const model = fixture('runtime:account', { + kind: 'task-source', + provider: 'jira', + projectId: 'project', + hostId: 'runtime:source' + }) + const { result } = renderHook(() => useTaskPageJiraListState(model)) + await selectPriority(result) + expect(transport.priorities).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'source' }, + 'site-1' + ) + }) + + it.each(['local', 'ssh:example'] as const)( + 'uses the local IPC route for the %s account while another server is focused', + async (hostId) => { + const model = fixture(hostId) + const { result } = renderHook(() => useTaskPageJiraListState(model)) + await selectPriority(result) + expect(transport.priorities).toHaveBeenCalledWith({ kind: 'local' }, 'site-1') + } + ) + + it.each(['account', 'source'] as const)( + 'rejects late priorities after the %s owner changes', + async (kind) => { + let resolveOld: (priorities: JiraPriority[]) => void = () => {} + const oldPriorities = new Promise((resolve) => { + resolveOld = resolve + }) + const latest = [{ id: 'latest', name: 'Latest scheme' }] + transport.priorities.mockReturnValueOnce(oldPriorities).mockResolvedValue(latest) + const source: TaskSourceContext | null = + kind === 'source' + ? { kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:old' } + : null + const model = fixture('runtime:old', source) + const { result, rerender } = renderHook(() => useTaskPageJiraListState(model)) + await selectPriority(result) + await act(async () => { + if (source) { + model.jiraTaskSourceContext = { ...source, hostId: 'runtime:new' } + } else { + model.accountBackedTaskSourceHostId = 'runtime:new' + } + rerender() + }) + await waitFor(() => expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(latest)) + expect(transport.priorities).toHaveBeenLastCalledWith( + { kind: 'environment', environmentId: 'new' }, + 'site-1' + ) + await act(async () => resolveOld([{ id: 'old', name: 'Old scheme' }])) + expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(latest) + } + ) + + it.each(['account', 'source'] as const)( + 'hides an already loaded scheme during the next %s owner read', + async (kind) => { + const old = [{ id: 'old', name: 'Old scheme' }] + const response = Promise.withResolvers() + transport.priorities.mockResolvedValueOnce(old).mockReturnValueOnce(response.promise) + const source: TaskSourceContext | null = + kind === 'source' + ? { kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:old' } + : null + const model = fixture('runtime:old', source) + const { result, rerender } = renderHook(() => useTaskPageJiraListState(model)) + await selectPriority(result) + await waitFor(() => expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(old)) + await act(async () => { + if (source) { + model.jiraTaskSourceContext = { ...source, hostId: 'runtime:new' } + } else { + model.accountBackedTaskSourceHostId = 'runtime:new' + } + rerender() + }) + expect(result.current.jiraPrioritiesBySite.size).toBe(0) + await act(async () => response.resolve([{ id: 'new', name: 'New scheme' }])) + expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual([ + { id: 'new', name: 'New scheme' } + ]) + } + ) + + it.each(['account', 'source'] as const)( + 'fails closed for an unresolved %s owner', + async (kind) => { + const model = fixture( + kind === 'account' ? 'runtime:unresolved-owner' : 'local', + kind === 'source' + ? { + kind: 'task-source', + provider: 'jira', + projectId: 'project', + hostId: 'runtime:unresolved-owner' + } + : null + ) + const { result } = renderHook(() => useTaskPageJiraListState(model)) + await act(async () => result.current.setJiraOrderBy('priority')) + expect(transport.priorities).not.toHaveBeenCalled() + expect(result.current.jiraPrioritiesBySite.size).toBe(0) + } + ) +}) 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 cd3cf223f18..d734fd9902c 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 @@ -10,11 +10,36 @@ import type { JiraIssueSortDirection, JiraPrioritiesBySite } from './jira-issue-sorter' -import { jiraListPriorities } from '@/runtime/runtime-jira-client' +import { jiraListPrioritiesForTarget } from '@/runtime/runtime-jira-client' import { loadJiraLinkedView, saveJiraLinkedView } from './jira-linked-view-storage' -import { defaultScopeSource } from '@/lib/default-creation-host' +import { + fallbackJiraSortColumn, + loadJiraListColumnIds, + saveJiraListColumnIds, + visibleJiraListColumns, + type JiraListColumnId +} from './jira-list-columns' +import { + ipcOrRuntimeTargetForHostId, + runtimeTargetEnvironmentId +} from '@/runtime/runtime-client-target' +import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context' +import { getTaskSourceCacheScope } from '../../../shared/task-source-context' +const EMPTY_JIRA_PRIORITIES: JiraPrioritiesBySite = new Map() + export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { - const { settings, jiraConnected, selectedJiraSiteId, taskSource, jiraTaskSourceContext } = model + const { + jiraConnected, + selectedJiraSiteId, + taskSource, + jiraTaskSourceContext, + accountBackedTaskSourceHostId + } = model + const owner = useMemo( + () => + ipcOrRuntimeTargetForHostId(jiraTaskSourceContext?.hostId ?? accountBackedTaskSourceHostId), + [accountBackedTaskSourceHostId, jiraTaskSourceContext] + ) // Jira tab state const [jiraIssues, setJiraIssues] = useState([]) const [jiraLoading, setJiraLoading] = useState(false) @@ -39,10 +64,39 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { order: JiraProjectStatusOrder scopeKey: string } | null>(null) - const [jiraOrderBy, setJiraOrderBy] = useState('updated') - const [jiraOrderDirection, setJiraOrderDirection] = useState('desc') - const [jiraPrioritiesBySite, setJiraPrioritiesBySite] = useState( - () => new Map() + const [jiraColumnIds, setJiraColumnIds] = + useState>(loadJiraListColumnIds) + const jiraColumns = useMemo(() => visibleJiraListColumns(jiraColumnIds), [jiraColumnIds]) + const [jiraOrderBy, setJiraOrderBy] = useState(() => + fallbackJiraSortColumn(jiraColumnIds) + ) + const [jiraOrderDirection, setJiraOrderDirection] = useState(() => + jiraOrderBy === 'updated' ? 'desc' : 'asc' + ) + const [prioritySnapshot, setPrioritySnapshot] = useState<{ + scopeKey: string + priorities: JiraPrioritiesBySite + } | null>(null) + const toggleJiraColumn = useCallback( + (column: JiraListColumnId) => { + if (column === 'key' || column === 'title') { + return + } + const next = new Set(jiraColumnIds) + if (next.has(column)) { + next.delete(column) + } else { + next.add(column) + } + if (!next.has(jiraOrderBy)) { + const fallback = fallbackJiraSortColumn(next) + setJiraOrderBy(fallback) + setJiraOrderDirection(fallback === 'updated' ? 'desc' : 'asc') + } + saveJiraListColumnIds(next) + setJiraColumnIds(next) + }, + [jiraColumnIds, jiraOrderBy] ) const jiraPrioritySiteIdsKey = useMemo(() => { const siteIds = @@ -52,40 +106,42 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { // Why: result refreshes replace the issue array; depend on the represented sites, not identity. return JSON.stringify([...new Set(siteIds)].sort()) }, [jiraIssues, selectedJiraSiteId]) + const priorityScopeKey = JSON.stringify([ + owner ? getEnvironmentRuntimeContextKey(runtimeTargetEnvironmentId(owner)) : null, + jiraTaskSourceContext ? getTaskSourceCacheScope(jiraTaskSourceContext) : null, + jiraPrioritySiteIdsKey + ]) + const prioritiesEnabled = taskSource === 'jira' && jiraConnected && jiraOrderBy === 'priority' + const jiraPrioritiesBySite: JiraPrioritiesBySite = + prioritiesEnabled && owner && prioritySnapshot?.scopeKey === priorityScopeKey + ? prioritySnapshot.priorities + : EMPTY_JIRA_PRIORITIES useEffect(() => { - if (taskSource !== 'jira' || !jiraConnected || jiraOrderBy !== 'priority') { - setJiraPrioritiesBySite((current) => (current.size === 0 ? current : new Map())) + if (!prioritiesEnabled || !owner) { return } let cancelled = false - const jiraPrioritySiteIds = JSON.parse(jiraPrioritySiteIdsKey) as string[] + const decodedSiteIds: unknown = JSON.parse(jiraPrioritySiteIdsKey) + const jiraPrioritySiteIds = Array.isArray(decodedSiteIds) + ? decodedSiteIds.filter((siteId): siteId is string => typeof siteId === 'string') + : [] void Promise.all( jiraPrioritySiteIds.map(async (siteId) => { try { - return [ - siteId, - await jiraListPriorities(jiraTaskSourceContext ?? defaultScopeSource(settings), siteId) - ] as const + return [siteId, await jiraListPrioritiesForTarget(owner, siteId)] as const } catch { return [siteId, [] as JiraPriority[]] as const } }) ).then((prioritiesBySite) => { if (!cancelled) { - setJiraPrioritiesBySite(new Map(prioritiesBySite)) + setPrioritySnapshot({ scopeKey: priorityScopeKey, priorities: new Map(prioritiesBySite) }) } }) return () => { cancelled = true } - }, [ - jiraConnected, - jiraOrderBy, - jiraPrioritySiteIdsKey, - jiraTaskSourceContext, - settings, - taskSource - ]) + }, [prioritiesEnabled, jiraPrioritySiteIdsKey, priorityScopeKey, owner]) const handleJiraSort = useCallback( (column: JiraIssueSortColumn) => { if (jiraOrderBy === column) { @@ -126,9 +182,11 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { jiraOrderDirection, setJiraOrderDirection, jiraPrioritiesBySite, - setJiraPrioritiesBySite, jiraPrioritySiteIdsKey, - handleJiraSort + handleJiraSort, + jiraColumnIds, + jiraColumns, + toggleJiraColumn }) } export type TaskPageJiraListStateModel = ReturnType diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2b4260a6cef..4c931800c77 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2537,6 +2537,12 @@ "linearFetchMore": "Fetch more", "jiraSortAscending": "ascending", "jiraSortDescending": "descending", + "jiraColumnsPicker": "Columns", + "jiraColumnParent": "Parent", + "jiraColumnSprint": "Sprint", + "jiraColumnStoryPoints": "Points", + "jiraColumnOriginalEstimate": "Estimate", + "jiraColumnRemainingEstimate": "Remaining", "jiraSortBy": "Sort by", "jiraToggleSortDirection": "Sort {{value0}}", "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 5195c55fafe..68fa5e71657 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -21,7 +21,6 @@ import { 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' - import { jiraGetIssueForTarget, jiraUpdateIssueForTarget, diff --git a/src/renderer/src/runtime/runtime-jira-priority-client.test.ts b/src/renderer/src/runtime/runtime-jira-priority-client.test.ts new file mode 100644 index 00000000000..6ca36965516 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-priority-client.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { jiraListPriorities } from './runtime-jira-client' +import { jiraListPrioritiesForTarget } from './runtime-jira-detail-client' +import { defaultScopeSource } from '@/lib/default-creation-host' + +const transport = vi.hoisted(() => ({ rpc: vi.fn(), local: vi.fn() })) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: transport.rpc })) + +beforeEach(() => { + vi.clearAllMocks() + transport.rpc.mockResolvedValue([]) + transport.local.mockResolvedValue([]) + vi.stubGlobal('window', { api: { jira: { listPriorities: transport.local } } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('Jira priority owner transport', () => { + it('keeps local site selection and returned priorities intact', async () => { + const priorities = [{ id: 'custom', name: 'Immediate' }] + transport.local.mockResolvedValue(priorities) + await expect(jiraListPrioritiesForTarget({ kind: 'local' }, 'site-1')).resolves.toBe(priorities) + expect(transport.local).toHaveBeenCalledWith({ siteId: 'site-1' }) + expect(transport.rpc).not.toHaveBeenCalled() + }) + + it('keeps absent site selection absent', async () => { + await jiraListPrioritiesForTarget({ kind: 'local' }, null) + expect(transport.local).toHaveBeenCalledWith(undefined) + }) + + it('sends the captured remote owner, selected site, and existing deadline', async () => { + const target = { kind: 'environment' as const, environmentId: 'owner' } + await jiraListPrioritiesForTarget(target, 'site-1') + expect(transport.rpc).toHaveBeenCalledWith( + target, + 'jira.listPriorities', + { siteId: 'site-1' }, + { timeoutMs: 30_000 } + ) + expect(transport.local).not.toHaveBeenCalled() + }) + + it('preserves the settings compatibility entry point', async () => { + await jiraListPriorities(defaultScopeSource({ activeRuntimeEnvironmentId: 'legacy' }), null) + expect(transport.rpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'legacy' }, + 'jira.listPriorities', + undefined, + { timeoutMs: 30_000 } + ) + }) + + it('preserves the explicit-source compatibility entry point', async () => { + await jiraListPriorities( + { kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:source' }, + 'site-1' + ) + expect(transport.rpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'source' }, + 'jira.listPriorities', + { siteId: 'site-1' }, + { timeoutMs: 30_000 } + ) + }) + + it('propagates remote failure without querying the local account', async () => { + transport.rpc.mockRejectedValue(new Error('Owner unavailable')) + await expect( + jiraListPrioritiesForTarget({ kind: 'environment', environmentId: 'owner' }, 'site-1') + ).rejects.toThrow('Owner unavailable') + expect(transport.local).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index de9c25f95d6..df768f99291 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -120,6 +120,12 @@ export type JiraIssue = { assignee?: JiraUser reporter?: JiraUser priority?: JiraPriority + /** Parent issue (epic for stories, parent story for subtasks). */ + parent?: { key: string; title: string; issueTypeName?: string } + sprint?: string + storyPoints?: number + originalEstimateSeconds?: number + remainingEstimateSeconds?: number updatedAt: string createdAt: string } diff --git a/tests/e2e/jira-panel-layout.spec.ts b/tests/e2e/jira-panel-layout.spec.ts index eec02b28624..ff9218ab045 100644 --- a/tests/e2e/jira-panel-layout.spec.ts +++ b/tests/e2e/jira-panel-layout.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@stablyai/playwright-test' import type { JiraIssue } from '../../src/shared/jira-types' import { expect, test } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' @@ -13,18 +14,21 @@ const ISSUE: JiraIssue = { issueType: { id: 'task', name: 'Task' }, status: { id: 'open', name: 'To Do', categoryKey: 'new', categoryName: 'To Do' }, labels: [], + parent: { key: 'DEMO-10', title: 'Epic planning' }, + sprint: 'Sprint 12', + storyPoints: 5, + originalEstimateSeconds: 7200, + remainingEstimateSeconds: 1800, priority: { id: 'high', name: 'High' }, assignee: { accountId: 'layout-user', displayName: 'Demo User' }, createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-13T00:00:00Z' } -test('Jira titles and sort headings adapt to panel width inside a wide window', async ({ - orcaPage -}) => { - await waitForSessionReady(orcaPage) - await orcaPage.setViewportSize({ width: 1500, height: 900 }) - await orcaPage.evaluate((issue) => { +async function openFixtureJiraList(page: Page): Promise { + await waitForSessionReady(page) + await page.setViewportSize({ width: 1500, height: 900 }) + await page.evaluate((issue) => { const store = window.__store if (!store) { throw new Error('Store unavailable') @@ -59,6 +63,12 @@ test('Jira titles and sort headings adapt to panel width inside a wide window', }) store.getState().openTaskPage({ taskSource: 'jira' }) }, ISSUE) +} + +test('Jira titles and sort headings adapt to panel width inside a wide window', async ({ + orcaPage +}) => { + await openFixtureJiraList(orcaPage) const title = orcaPage.getByRole('heading', { name: ISSUE_TITLE }) await expect(title).toBeVisible() @@ -84,7 +94,7 @@ test('Jira titles and sort headings adapt to panel width inside a wide window', const grids = await title.evaluate((element) => { const row = element.closest('[role="button"]') const host = row?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement - const header = host?.children[1] + const header = host?.querySelector('button[aria-pressed]')?.parentElement if (!row || !header) { throw new Error('Jira header or row unavailable') } @@ -100,3 +110,64 @@ test('Jira titles and sort headings adapt to panel width inside a wide window', } } }) + +test('Jira planning columns persist and hiding a sorted field selects a visible fallback', async ({ + orcaPage +}) => { + await openFixtureJiraList(orcaPage) + const title = orcaPage.getByRole('heading', { name: ISSUE_TITLE }) + await expect(title).toBeVisible() + await title.evaluate((element) => { + const host = + element.closest('[role="button"]')?.parentElement?.parentElement?.parentElement?.parentElement + ?.parentElement + if (!(host instanceof HTMLElement)) { + throw new Error('Jira list host unavailable') + } + host.style.width = '780px' + }) + await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click() + for (const name of ['Parent', 'Sprint', 'Points', 'Estimate', 'Remaining']) { + await orcaPage.getByRole('menuitemcheckbox', { name, exact: true }).click() + } + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.getByText('Epic planning', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('Sprint 12', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('2h', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('30m', { exact: true })).toBeVisible() + const layout = await title.evaluate((element) => { + const row = element.closest('[role="button"]') + const scroll = row?.parentElement?.parentElement?.parentElement?.parentElement + if (!(scroll instanceof HTMLElement)) { + throw new Error('Scroll container unavailable') + } + return { + title: element.getBoundingClientRect().width, + content: scroll.scrollWidth, + viewport: scroll.clientWidth + } + }) + expect(layout.title).toBeGreaterThanOrEqual(180) + expect(layout.content).toBeGreaterThan(layout.viewport) + await orcaPage.getByRole('button', { name: 'Points', exact: true }).click() + await expect(orcaPage.getByRole('button', { name: 'Points, ascending' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click() + await orcaPage.getByRole('menuitemcheckbox', { name: 'Points', exact: true }).click() + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.getByRole('button', { name: 'Updated, descending' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await orcaPage.reload() + await openFixtureJiraList(orcaPage) + await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click() + await expect( + orcaPage.getByRole('menuitemcheckbox', { name: 'Sprint', exact: true }) + ).toHaveAttribute('aria-checked', 'true') + await expect( + orcaPage.getByRole('menuitemcheckbox', { name: 'Points', exact: true }) + ).toHaveAttribute('aria-checked', 'false') +})