From d3f0420f6034e7c8a4e2f26af78fc1cf31accc7b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:23:37 -0700 Subject: [PATCH 1/5] fix(jira): make the Reporter field a real user picker so issues can be created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a Jira issue was impossible whenever a project marked Reporter required. Jira's createmeta returns `reporter` with `schema.type: "user"` and no `allowedValues`, so the create dialog rendered it as a plain text Input and never searched anyone; whatever was typed left the renderer as a bare string, which Jira Cloud rejects with "Reporter is required." — the same for a pasted accountId. - render a searched picker for any user-typed create field, scoped to the target project (create has no issue key, so the existing issue-scoped assignable search could not be reused as-is) - report a failed user search in the dropdown instead of showing it empty, including an unrecognized payload from an older paired host - carry a picked user as a provider-neutral marker the execution host resolves to Cloud `{id}` or Server/DC `{name}`, matching what updateIssue already does for assignees - keep a typed or pasted account id selectable, since Jira accepts one the directory search never surfaced - name the still-blank required fields under the form instead of leaving Create disabled with no explanation `jira.listAssignableUsers` keeps its array shape for paired older clients. --- src/main/ipc/jira.ts | 48 ++-- src/main/jira/issues.ts | 8 +- .../jira/jira-create-reporter-payload.test.ts | 110 +++++++++ src/main/jira/jira-issue-create-metadata.ts | 44 +--- src/main/jira/jira-issue-mutations.ts | 5 +- src/main/jira/jira-user-search.test.ts | 193 +++++++++++++++ src/main/jira/jira-user-search.ts | 81 +++++++ src/main/runtime/orca-runtime.ts | 9 + src/main/runtime/rpc/methods/jira.test.ts | 39 ++++ src/main/runtime/rpc/methods/jira.ts | 19 ++ src/preload/api/jira-api.ts | 3 + src/preload/index.ts | 7 + src/renderer/src/components/TaskPage.tsx | 6 +- .../task-page-jira-create-fields.test.ts | 62 +++++ .../task-page-jira-create-fields.ts | 28 +++ .../dialogs/jira-user-field-picker.test.tsx | 135 +++++++++++ .../dialogs/jira-user-field-picker.tsx | 220 ++++++++++++++++++ .../new-jira-issue-custom-fields.test.tsx | 73 ++++++ .../dialogs/new-jira-issue-custom-fields.tsx | 27 ++- .../dialogs/new-jira-issue-dialog.tsx | 21 +- .../use-task-page-jira-create-dialog.tsx | 46 +++- src/renderer/src/i18n/locales/en.json | 35 ++- .../src/runtime/runtime-jira-client.test.ts | 65 +++++- .../src/runtime/runtime-jira-client.ts | 1 + .../src/runtime/runtime-jira-user-search.ts | 61 +++++ src/shared/jira-types.ts | 14 ++ src/shared/jira-user-field-value.test.ts | 57 +++++ src/shared/jira-user-field-value.ts | 47 ++++ 28 files changed, 1383 insertions(+), 81 deletions(-) create mode 100644 src/main/jira/jira-create-reporter-payload.test.ts create mode 100644 src/main/jira/jira-user-search.test.ts create mode 100644 src/main/jira/jira-user-search.ts create mode 100644 src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx create mode 100644 src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx create mode 100644 src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx create mode 100644 src/renderer/src/runtime/runtime-jira-user-search.ts create mode 100644 src/shared/jira-user-field-value.test.ts create mode 100644 src/shared/jira-user-field-value.ts diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index b7faa6fd333..92b959ccb61 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -16,6 +16,7 @@ import { listPriorities, listProjects, listTransitions, + searchAssignableUsers, searchIssues, updateIssue } from '../jira/issues' @@ -31,12 +32,12 @@ const VALID_FILTERS = new Set(['assigned', 'reported', 'all', ' const issueSummaryRequests = new JiraCancellableRequests() const searchRequests = new JiraCancellableRequests() -function normalizeSiteId(value: unknown): string | undefined { +function normalizeTrimmedArg(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } function normalizeSiteSelection(value: unknown): JiraSiteSelection | undefined { - const siteId = normalizeSiteId(value) + const siteId = normalizeTrimmedArg(value) return siteId as JiraSiteSelection | undefined } @@ -105,7 +106,7 @@ export function registerJiraHandlers(): void { }) ipcMain.handle('jira:disconnect', async (_event, args?: { siteId?: string }) => { - disconnect(normalizeSiteId(args?.siteId)) + disconnect(normalizeTrimmedArg(args?.siteId)) _resetPreflightCache() }) @@ -126,7 +127,7 @@ export function registerJiraHandlers(): void { }) ipcMain.handle('jira:testConnection', async (_event, args?: { siteId?: string }) => { - return testConnection(normalizeSiteId(args?.siteId)) + return testConnection(normalizeTrimmedArg(args?.siteId)) }) ipcMain.handle( @@ -165,7 +166,7 @@ export function registerJiraHandlers(): void { if (typeof args?.key !== 'string' || !args.key.trim()) { return null } - return getIssue(args.key.trim(), normalizeSiteId(args.siteId)) + return getIssue(args.key.trim(), normalizeTrimmedArg(args.siteId)) }) ipcMain.handle( @@ -200,7 +201,7 @@ export function registerJiraHandlers(): void { return { ok: false, error: 'Title is required.' } } return createIssue({ - siteId: normalizeSiteId(args.siteId), + siteId: normalizeTrimmedArg(args.siteId), projectId: args.projectId.trim(), issueTypeId: args.issueTypeId.trim(), title: args.title.trim(), @@ -220,7 +221,7 @@ export function registerJiraHandlers(): void { if (!updates) { return { ok: false, error: 'Updates object is required.' } } - return updateIssue(args.key.trim(), updates, normalizeSiteId(args.siteId)) + return updateIssue(args.key.trim(), updates, normalizeTrimmedArg(args.siteId)) } ) @@ -233,7 +234,7 @@ export function registerJiraHandlers(): void { if (typeof args?.body !== 'string' || !args.body.trim()) { return { ok: false, error: 'Comment body is required.' } } - return addIssueComment(args.key.trim(), args.body.trim(), normalizeSiteId(args.siteId)) + return addIssueComment(args.key.trim(), args.body.trim(), normalizeTrimmedArg(args.siteId)) } ) @@ -241,7 +242,7 @@ export function registerJiraHandlers(): void { if (typeof args?.key !== 'string' || !args.key.trim()) { return [] } - return getIssueComments(args.key.trim(), normalizeSiteId(args.siteId)) + return getIssueComments(args.key.trim(), normalizeTrimmedArg(args.siteId)) }) ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => { @@ -254,7 +255,7 @@ export function registerJiraHandlers(): void { if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { return [] } - return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId)) + return listIssueTypes(args.projectIdOrKey.trim(), normalizeTrimmedArg(args.siteId)) } ) @@ -270,13 +271,13 @@ export function registerJiraHandlers(): void { return listCreateFields( args.projectIdOrKey.trim(), args.issueTypeId.trim(), - normalizeSiteId(args.siteId) + normalizeTrimmedArg(args.siteId) ) } ) ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => { - return listPriorities(normalizeSiteId(args?.siteId)) + return listPriorities(normalizeTrimmedArg(args?.siteId)) }) ipcMain.handle( @@ -288,7 +289,24 @@ export function registerJiraHandlers(): void { return listAssignableUsers( args.key.trim(), typeof args.query === 'string' ? args.query : undefined, - normalizeSiteId(args.siteId) + normalizeTrimmedArg(args.siteId) + ) + } + ) + + ipcMain.handle( + 'jira:searchUsers', + async ( + _event, + args: { projectIdOrKey?: string; issueKey?: string; query?: string; siteId?: string } + ) => { + return searchAssignableUsers( + { + projectIdOrKey: normalizeTrimmedArg(args?.projectIdOrKey), + issueKey: normalizeTrimmedArg(args?.issueKey) + }, + typeof args?.query === 'string' ? args.query : undefined, + normalizeTrimmedArg(args?.siteId) ) } ) @@ -297,7 +315,7 @@ export function registerJiraHandlers(): void { if (typeof args?.key !== 'string' || !args.key.trim()) { return [] } - return listTransitions(args.key.trim(), normalizeSiteId(args.siteId)) + return listTransitions(args.key.trim(), normalizeTrimmedArg(args.siteId)) }) ipcMain.handle( @@ -306,7 +324,7 @@ export function registerJiraHandlers(): void { if (typeof args?.projectKey !== 'string' || !args.projectKey.trim()) { return { statusIdsByColumn: [] } } - return getProjectStatusOrder(args.projectKey.trim(), normalizeSiteId(args.siteId)) + return getProjectStatusOrder(args.projectKey.trim(), normalizeTrimmedArg(args.siteId)) } ) } diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 7529c2131da..befcafaa831 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -4,10 +4,6 @@ export { getIssue, getIssueSummary } from './jira-issue-read' export { addIssueComment, createIssue, updateIssue } from './jira-issue-mutations' export { getIssueComments } from './jira-issue-comments' export { listProjects } from './jira-project-queries' -export { - listAssignableUsers, - listCreateFields, - listIssueTypes, - listPriorities -} from './jira-issue-create-metadata' +export { listCreateFields, listIssueTypes, listPriorities } from './jira-issue-create-metadata' +export { listAssignableUsers, searchAssignableUsers } from './jira-user-search' export { getProjectStatusOrder, listTransitions } from './jira-transition-queries' diff --git a/src/main/jira/jira-create-reporter-payload.test.ts b/src/main/jira/jira-create-reporter-payload.test.ts new file mode 100644 index 00000000000..ac006e95ff5 --- /dev/null +++ b/src/main/jira/jira-create-reporter-payload.test.ts @@ -0,0 +1,110 @@ +// STA-2709: creating a Jira issue failed with "Reporter is required." because the +// renderer sent the reporter as text and createIssue forwarded it verbatim. This +// pins the whole seam — picked account id in, `{"reporter":{"id":...}}` out. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' +import { buildJiraUserFieldValue } from '../../shared/jira-user-field-value' + +const { getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.hoisted(() => ({ + getClientsMock: vi.fn(), + isAuthErrorMock: vi.fn(), + jiraRequestMock: vi.fn() +})) + +vi.mock('./request-queue', () => ({ acquire: vi.fn(), release: vi.fn() })) + +vi.mock('./authenticated-request', () => ({ + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) +})) + +vi.mock('./client', () => ({ + clearToken: vi.fn(), + getClients: (...args: unknown[]) => getClientsMock(...args), + isAuthError: (...args: unknown[]) => isAuthErrorMock(...args) +})) + +function entry(authType?: 'cloud' | 'server'): JiraClientForSite { + return { + site: { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Example Jira', + accountId: 'account-1', + ...(authType ? { authType } : {}) + }, + authorization: 'Basic token' + } +} + +function postedFields(): Record { + const init = jiraRequestMock.mock.calls[0]?.[2] as { body?: string } | undefined + return ( + (JSON.parse(String(init?.body ?? '{}')) as { fields?: Record }).fields ?? {} + ) +} + +async function createWithReporter( + draft: string, + authType?: 'cloud' | 'server' +): Promise> { + getClientsMock.mockReturnValue([entry(authType)]) + jiraRequestMock.mockResolvedValue({ id: '1', key: 'ENG-1', self: 'https://example' }) + const { createIssue } = await import('./jira-issue-mutations') + const result = await createIssue({ + projectId: '100', + issueTypeId: '10001', + title: 'Broken login', + // Exactly the marker the create dialog builds from a picked user; the + // renderer half of that seam is pinned in task-page-jira-create-fields.test. + customFields: { reporter: buildJiraUserFieldValue(draft) } + }) + expect(result.ok).toBe(true) + return postedFields() +} + +describe('Jira create reporter payload', () => { + beforeEach(() => { + vi.clearAllMocks() + isAuthErrorMock.mockReturnValue(false) + jiraRequestMock.mockReset() + }) + + it('sends a picked Cloud account id as {"reporter":{"id":...}}', async () => { + const fields = await createWithReporter('5b10a2844c20165700ede21g') + + expect(fields.reporter).toEqual({ id: '5b10a2844c20165700ede21g' }) + expect(typeof fields.reporter).not.toBe('string') + }) + + it('sends a Server/DC username as {"reporter":{"name":...}}, which has no accountId', async () => { + const fields = await createWithReporter('ada', 'server') + + expect(fields.reporter).toEqual({ name: 'ada' }) + }) + + it('omits the reporter entirely when nothing was picked', async () => { + const fields = await createWithReporter(' ') + + expect('reporter' in fields).toBe(false) + }) + + it('leaves option-shaped custom fields alone', async () => { + getClientsMock.mockReturnValue([entry()]) + jiraRequestMock.mockResolvedValue({ id: '1', key: 'ENG-1', self: 'https://example' }) + const { createIssue } = await import('./jira-issue-mutations') + + await createIssue({ + projectId: '100', + issueTypeId: '10001', + title: 'Broken login', + customFields: { customfield_1: { id: 'opt-1' }, customfield_2: 'free text' } + }) + + const fields = postedFields() + expect(fields.customfield_1).toEqual({ id: 'opt-1' }) + expect(fields.customfield_2).toBe('free text') + }) +}) diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts index e3c5338bd49..630a4b9623b 100644 --- a/src/main/jira/jira-issue-create-metadata.ts +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -1,9 +1,4 @@ -import type { - JiraCreateField, - JiraIssueType, - JiraPriority, - JiraUser -} from '../../shared/jira-types' +import type { JiraCreateField, JiraIssueType, JiraPriority } from '../../shared/jira-types' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' @@ -11,8 +6,7 @@ import { getCreateFieldRecords, mapCreateField, mapIssueType, - mapPriority, - mapUser + mapPriority } from './jira-issue-mapping' import { asFiniteNumber, @@ -124,37 +118,3 @@ export async function listPriorities(siteId?: string | null): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - const isServer = entry.site.authType === 'server' - const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) - if (query?.trim()) { - // Server/DC filters assignable users by `username`; `query` is Cloud-only. - params.set(isServer ? 'username' : 'query', query.trim()) - } - await acquire() - try { - const response = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` - ) - return response.map(mapUser).filter((user): user is JiraUser => !!user) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listAssignableUsers failed:', error) - return [] - } finally { - release() - } -} diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts index 29b5fd7a679..94c62b02704 100644 --- a/src/main/jira/jira-issue-mutations.ts +++ b/src/main/jira/jira-issue-mutations.ts @@ -7,6 +7,7 @@ import type { import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' +import { resolveJiraUserFieldValues } from '../../shared/jira-user-field-value' import { issueUrl, toBodyText } from './jira-issue-mapping' import type { JiraRecord } from './jira-record-pages' @@ -34,7 +35,9 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise( entry, diff --git a/src/main/jira/jira-user-search.test.ts b/src/main/jira/jira-user-search.test.ts new file mode 100644 index 00000000000..f8429eb4504 --- /dev/null +++ b/src/main/jira/jira-user-search.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' + +const { + clearTokenMock, + getClientsMock, + isAuthErrorMock, + jiraRequestMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + clearTokenMock: vi.fn(), + getClientsMock: vi.fn(), + isAuthErrorMock: vi.fn(), + 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) +})) + +vi.mock('./client', () => ({ + clearToken: (...args: unknown[]) => clearTokenMock(...args), + getClients: (...args: unknown[]) => getClientsMock(...args), + isAuthError: (...args: unknown[]) => isAuthErrorMock(...args) +})) + +function cloudEntry(): JiraClientForSite { + return { + site: { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Example Jira', + accountId: 'account-1' + }, + authorization: 'Basic token' + } +} + +function serverEntry(): JiraClientForSite { + return { + site: { + id: 'server-1', + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'Self-hosted Jira', + accountId: 'ada', + authType: 'server' + }, + authorization: 'Bearer pat' + } +} + +function requestedPath(): string { + return String(jiraRequestMock.mock.calls[0]?.[1] ?? '') +} + +describe('searchAssignableUsers', () => { + beforeEach(() => { + vi.clearAllMocks() + isAuthErrorMock.mockReturnValue(false) + getClientsMock.mockReturnValue([cloudEntry()]) + acquireMock.mockResolvedValue(undefined) + jiraRequestMock.mockReset() + }) + + it('scopes a create-time search to the project, since no issue key exists yet', async () => { + jiraRequestMock.mockResolvedValue([ + { accountId: '5abc', displayName: 'Alex Rivera', emailAddress: 'alex@example.com' } + ]) + const { searchAssignableUsers } = await import('./jira-user-search') + + const result = await searchAssignableUsers({ projectIdOrKey: 'ENG' }, 'Alex') + + const path = requestedPath() + expect(path).toContain('/rest/api/3/user/assignable/search?') + expect(path).toContain('project=ENG') + expect(path).toContain('query=Alex') + expect(path).not.toContain('issueKey=') + expect(result).toEqual({ + ok: true, + users: [ + { + accountId: '5abc', + displayName: 'Alex Rivera', + email: 'alex@example.com', + avatarUrl: undefined + } + ] + }) + }) + + it('keeps the issue-scoped search for an existing issue', async () => { + jiraRequestMock.mockResolvedValue([]) + const { searchAssignableUsers } = await import('./jira-user-search') + + await searchAssignableUsers({ issueKey: 'ENG-1' }, 'Alex') + + expect(requestedPath()).toContain('issueKey=ENG-1') + expect(requestedPath()).not.toContain('project=') + }) + + it('filters Server/DC by username, which has no Cloud query parameter', async () => { + getClientsMock.mockReturnValue([serverEntry()]) + jiraRequestMock.mockResolvedValue([]) + const { searchAssignableUsers } = await import('./jira-user-search') + + await searchAssignableUsers({ projectIdOrKey: 'ENG' }, 'ada') + + const path = requestedPath() + expect(path).toContain('/rest/api/2/user/assignable/search?') + expect(path).toContain('username=ada') + expect(path).not.toContain('query=ada') + }) + + it('reports a failed search instead of an empty list', async () => { + jiraRequestMock.mockRejectedValue(new Error('You do not have permission to browse users.')) + const { searchAssignableUsers } = await import('./jira-user-search') + + const result = await searchAssignableUsers({ projectIdOrKey: 'ENG' }, 'Alex') + + expect(result).toEqual({ + ok: false, + error: 'You do not have permission to browse users.' + }) + }) + + it('reports a disconnected site rather than looking like a directory with no users', async () => { + getClientsMock.mockReturnValue([]) + const { searchAssignableUsers } = await import('./jira-user-search') + + expect(await searchAssignableUsers({ projectIdOrKey: 'ENG' })).toEqual({ + ok: false, + error: 'Not connected to Jira.' + }) + expect(jiraRequestMock).not.toHaveBeenCalled() + }) + + it('refuses an unscoped search', async () => { + const { searchAssignableUsers } = await import('./jira-user-search') + + expect(await searchAssignableUsers({ projectIdOrKey: ' ', issueKey: '' }, 'Alex')).toEqual({ + ok: false, + error: 'A Jira project or issue is required to search users.' + }) + expect(jiraRequestMock).not.toHaveBeenCalled() + }) + + it('clears the token and rethrows on an auth failure', async () => { + const authError = new Error('Unauthorized') + isAuthErrorMock.mockReturnValue(true) + jiraRequestMock.mockRejectedValue(authError) + const { searchAssignableUsers } = await import('./jira-user-search') + + await expect(searchAssignableUsers({ projectIdOrKey: 'ENG' })).rejects.toBe(authError) + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) +}) + +describe('listAssignableUsers', () => { + beforeEach(() => { + vi.clearAllMocks() + isAuthErrorMock.mockReturnValue(false) + getClientsMock.mockReturnValue([cloudEntry()]) + jiraRequestMock.mockReset() + }) + + // The `jira.listAssignableUsers` RPC is array-shaped on the wire and older + // paired clients call .map on it, so the failure path must stay an empty array. + it('still returns an array when the search fails', async () => { + jiraRequestMock.mockRejectedValue(new Error('boom')) + const { listAssignableUsers } = await import('./jira-user-search') + + await expect(listAssignableUsers('ENG-1', 'Alex')).resolves.toEqual([]) + }) + + it('returns the mapped users on success', async () => { + jiraRequestMock.mockResolvedValue([{ accountId: '5abc', displayName: 'Alex Rivera' }]) + const { listAssignableUsers } = await import('./jira-user-search') + + await expect(listAssignableUsers('ENG-1')).resolves.toEqual([ + { accountId: '5abc', displayName: 'Alex Rivera', email: undefined, avatarUrl: undefined } + ]) + expect(requestedPath()).toContain('issueKey=ENG-1') + }) +}) diff --git a/src/main/jira/jira-user-search.ts b/src/main/jira/jira-user-search.ts new file mode 100644 index 00000000000..f3c1144268b --- /dev/null +++ b/src/main/jira/jira-user-search.ts @@ -0,0 +1,81 @@ +import type { JiraUser, JiraUserSearchResult } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapUser } from './jira-issue-mapping' +import type { JiraRecord } from './jira-record-pages' + +function buildAssignableSearchParams( + authType: string | undefined, + scope: { issueKey?: string; projectIdOrKey?: string }, + query?: string +): URLSearchParams { + const params = new URLSearchParams({ maxResults: '50' }) + if (scope.issueKey) { + params.set('issueKey', scope.issueKey) + } else if (scope.projectIdOrKey) { + // Create has no issue yet; Cloud and Server/DC both scope by project here. + params.set('project', scope.projectIdOrKey) + } + if (query?.trim()) { + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(authType === 'server' ? 'username' : 'query', query.trim()) + } + return params +} + +export async function searchAssignableUsers( + scope: { issueKey?: string | null; projectIdOrKey?: string | null }, + query?: string, + siteId?: string | null +): Promise { + const issueKey = scope.issueKey?.trim() || undefined + const projectIdOrKey = scope.projectIdOrKey?.trim() || undefined + if (!issueKey && !projectIdOrKey) { + return { ok: false, error: 'A Jira project or issue is required to search users.' } + } + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + const params = buildAssignableSearchParams( + entry.site.authType, + { issueKey, projectIdOrKey }, + query + ) + await acquire() + try { + const response = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` + ) + return { + ok: true, + users: response.map(mapUser).filter((user): user is JiraUser => !!user) + } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] searchAssignableUsers failed:', error) + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to search Jira users.' + } + } finally { + release() + } +} + +// Kept array-shaped: `jira.listAssignableUsers` is an established RPC whose +// existing readers call .map on the result, so a paired older client must keep +// receiving an array. New callers should use searchAssignableUsers. +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + const result = await searchAssignableUsers({ issueKey: key }, query, siteId) + return result.ok ? result.users : [] +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3fd419cb69b..994c20b1e12 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1078,6 +1078,7 @@ import { getIssueComments as getJiraIssueComments, getProjectStatusOrder as getJiraProjectStatusOrder, listAssignableUsers as listJiraAssignableUsers, + searchAssignableUsers as searchJiraAssignableUsers, listCreateFields as listJiraCreateFields, listIssueTypes as listJiraIssueTypes, listIssues as listJiraIssues, @@ -40764,6 +40765,14 @@ export class OrcaRuntimeService { return listJiraAssignableUsers(key, query, siteId) } + jiraSearchUsers( + scope: { issueKey?: string; projectIdOrKey?: string }, + query?: string, + siteId?: string + ): ReturnType { + return searchJiraAssignableUsers(scope, query, siteId) + } + jiraListTransitions(key: string, siteId?: string): ReturnType { return listJiraTransitions(key, siteId) } diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 6667188f88d..055e7e7096d 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -217,4 +217,43 @@ describe('jira RPC methods', () => { expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1') expect(runtime.jiraGetProjectStatusOrder).toHaveBeenCalledWith('ALP', 'site-1') }) + + it('accepts a project-scoped user search, which create needs before an issue exists', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraSearchUsers: vi.fn().mockResolvedValue({ ok: true, users: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('jira.searchUsers', { + projectIdOrKey: 'ALP', + query: 'Ada', + siteId: 'site-1' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.jiraSearchUsers).toHaveBeenCalledWith( + { projectIdOrKey: 'ALP', issueKey: undefined }, + 'Ada', + 'site-1' + ) + }) + + it('accepts an issue-scoped user search on the same method', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraSearchUsers: vi.fn().mockResolvedValue({ ok: true, users: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + + await dispatcher.dispatch(makeRequest('jira.searchUsers', { issueKey: 'ABC-3' })) + + expect(runtime.jiraSearchUsers).toHaveBeenCalledWith( + { projectIdOrKey: undefined, issueKey: 'ABC-3' }, + undefined, + undefined + ) + }) }) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index bd264f5c4d6..242231d2e90 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -94,6 +94,15 @@ const AssignableUsers = z.object({ siteId: OptionalString }) +// Both scopes optional: create has a project and no issue key, the issue view +// has a key and no project. +const UserSearch = z.object({ + projectIdOrKey: OptionalPlainString, + issueKey: OptionalPlainString, + query: OptionalPlainString, + siteId: OptionalString +}) + const ProjectStatusOrder = z.object({ projectKey: requiredString('Project key is required'), siteId: OptionalString @@ -253,6 +262,16 @@ export const JIRA_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId) }), + defineMethod({ + name: 'jira.searchUsers', + params: UserSearch, + handler: async (params, { runtime }) => + runtime.jiraSearchUsers( + { projectIdOrKey: params.projectIdOrKey, issueKey: params.issueKey }, + params.query, + params.siteId + ) + }), defineMethod({ name: 'jira.listTransitions', params: IssueKey, diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 1294850c284..c3783e8b28b 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -13,6 +13,8 @@ import type { JiraSiteSelection, JiraTransition, JiraUser, + JiraUserSearchArgs, + JiraUserSearchResult, JiraViewer } from '../../shared/jira-types' @@ -76,6 +78,7 @@ export type JiraApi = { query?: string siteId?: string }) => Promise + searchUsers: (args: JiraUserSearchArgs) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise getProjectStatusOrder: (args: { projectKey: string diff --git a/src/preload/index.ts b/src/preload/index.ts index c13e7a30a7d..f9374319da5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2114,6 +2114,13 @@ const api = { siteId?: string }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args: { + projectIdOrKey?: string + issueKey?: string + query?: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:searchUsers', args), + listTransitions: (args: { key: string; siteId?: string }): Promise => ipcRenderer.invoke('jira:listTransitions', args), getProjectStatusOrder: (args: { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 06cd8e745c9..25309d2062f 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1388,6 +1388,8 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueTargetType, visibleJiraCreateFields, hasMissingJiraCreateField, + missingJiraCreateFieldNames, + searchJiraCreateUsers, handleNewJiraIssueProjectComboboxOpenChange, handleNewJiraIssueProjectSelect, handleNewJiraIssueProjectTriggerKeyDown @@ -3078,7 +3080,9 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues, submitShortcutLabel, - hasMissingJiraCreateField + hasMissingJiraCreateField, + missingJiraCreateFieldNames, + searchJiraCreateUsers } const connectDialogs: TaskPageConnectDialogsProps = { gitlabDialogItem, diff --git a/src/renderer/src/components/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page-jira-create-fields.test.ts index 11210e347db..f54713c46de 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.test.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.test.ts @@ -6,8 +6,10 @@ import { findJiraCreateAllowedValue, getJiraCreateAllowedValueLabel, getJiraCreateOptionPayload, + isJiraUserCreateField, isVisibleJiraCreateField } from './task-page-jira-create-fields' +import { isJiraUserFieldValue } from '../../../shared/jira-user-field-value' import type { JiraCreateField } from '../../../shared/jira-types' function field(overrides: Partial = {}): JiraCreateField { @@ -171,3 +173,63 @@ describe('buildJiraCreateCustomFields', () => { expect(buildJiraCreateCustomFields([field({ key: 'a' })], { other: 'x' })).toBeUndefined() }) }) + +describe('isJiraUserCreateField', () => { + const cases: [string, JiraCreateField['schema'], boolean][] = [ + ['a single user field', { type: 'user' }, true], + ['a multi-user field', { type: 'array', items: 'user' }, true], + ['a user picker custom field', { type: 'user', custom: 'com.atlassian:userpicker' }, true], + ['an option field', { type: 'option' }, false], + ['a label array', { type: 'array', items: 'string' }, false], + ['an untyped field', undefined, false] + ] + + for (const [label, schema, expected] of cases) { + it(`returns ${expected} for ${label}`, () => { + expect(isJiraUserCreateField(field({ schema }))).toBe(expected) + }) + } +}) + +describe('buildJiraCreateFieldValue for user fields', () => { + // Jira Cloud rejects a bare string reporter with "Reporter is required." — the + // value has to leave the renderer as a user marker for the host to resolve. + it('wraps a picked account id instead of sending it as text', () => { + const reporter = field({ key: 'reporter', name: 'Reporter', schema: { type: 'user' } }) + expect(buildJiraCreateFieldValue(reporter, '5abc')).toEqual({ accountId: '5abc' }) + }) + + it('wraps every entry of a multi-user field', () => { + const participants = field({ + key: 'customfield_100', + schema: { type: 'array', items: 'user' } + }) + expect(buildJiraCreateFieldValue(participants, '5abc, 5def')).toEqual([ + { accountId: '5abc' }, + { accountId: '5def' } + ]) + }) + + it('drops a blank user field so create never sends an empty reporter', () => { + const reporter = field({ key: 'reporter', schema: { type: 'user' } }) + expect(buildJiraCreateFieldValue(reporter, ' ')).toBeUndefined() + expect( + buildJiraCreateFieldValue(field({ schema: { type: 'array', items: 'user' } }), ' , ') + ).toBeUndefined() + }) + + // The host recognizes the marker with this same predicate before rewriting it + // to Jira's per-deployment user shape. + it('produces a value the host recognizes as a user field', () => { + const reporter = field({ key: 'reporter', schema: { type: 'user' } }) + expect(isJiraUserFieldValue(buildJiraCreateFieldValue(reporter, '5abc'))).toBe(true) + expect(isJiraUserFieldValue(buildJiraCreateFieldValue(field({}), 'plain'))).toBe(false) + }) + + it('carries the reporter through the create payload builder', () => { + const reporter = field({ key: 'reporter', schema: { type: 'user' } }) + expect(buildJiraCreateCustomFields([reporter], { reporter: '5abc' })).toEqual({ + reporter: { accountId: '5abc' } + }) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page-jira-create-fields.ts index 932e64c2bb1..3f173ae003c 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -1,4 +1,5 @@ import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' +import { buildJiraUserFieldValue } from '../../../shared/jira-user-field-value' import type { JiraCreateField } from '../../../shared/jira-types' const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description']) @@ -7,6 +8,12 @@ export function isVisibleJiraCreateField(field: JiraCreateField): boolean { return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key) } +// Jira returns user fields (reporter, custom user pickers) with no allowedValues, +// so they need a searched picker rather than the option Select or a text box. +export function isJiraUserCreateField(field: JiraCreateField): boolean { + return field.schema?.type === 'user' || field.schema?.items === 'user' +} + export function getJiraCreateAllowedValueLabel( value: NonNullable[number] ): string { @@ -40,6 +47,16 @@ export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: st if (!trimmed) { return undefined } + if (isJiraUserCreateField(field)) { + if (field.schema?.type === 'array') { + const users = trimmed + .split(',') + .map((part) => buildJiraUserFieldValue(part)) + .filter((user) => user !== undefined) + return users.length > 0 ? users : undefined + } + return buildJiraUserFieldValue(trimmed) + } if (field.schema?.type === 'array') { const parts = trimmed .split(',') @@ -65,6 +82,17 @@ export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: st return trimmed } +// Names the fields still blank so the dialog can say what is missing instead of +// leaving Create disabled with no explanation. +export function getMissingJiraCreateFieldNames( + fields: readonly JiraCreateField[], + values: Record +): string[] { + return fields + .filter((field) => !(values[field.key] ?? '').trim()) + .map((field) => field.name || field.key) +} + export function buildJiraCreateCustomFields( fields: readonly JiraCreateField[], values: Record diff --git a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx new file mode 100644 index 00000000000..416282f38d1 --- /dev/null +++ b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JiraUserFieldPicker } from './jira-user-field-picker' +import type { JiraUserSearchResult } from '../../../../../shared/jira-types' + +afterEach(cleanup) + +const ALEX = { accountId: '5b10a2844c20165700ede21g', displayName: 'Alex Rivera' } + +function renderPicker( + searchUsers: (query: string) => Promise, + value = '', + onValueChange = vi.fn() +) { + render( + + ) + return { onValueChange } +} + +const SEARCH_PLACEHOLDER = 'Search users or paste an account ID...' + +async function open(): Promise { + fireEvent.click(screen.getByRole('combobox', { name: 'Reporter' })) + await waitFor(() => expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeTruthy()) +} + +describe('JiraUserFieldPicker', () => { + it('searches on open and binds the picked account id', async () => { + const searchUsers = vi.fn(async () => ({ ok: true as const, users: [ALEX] })) + const { onValueChange } = renderPicker(searchUsers) + + await open() + await waitFor(() => expect(screen.getByText('Alex Rivera')).toBeTruthy()) + expect(searchUsers).toHaveBeenCalledWith('') + + fireEvent.click(screen.getByText('Alex Rivera')) + expect(onValueChange).toHaveBeenCalledWith(ALEX.accountId) + }) + + it('says the directory is empty rather than staying blank', async () => { + renderPicker(vi.fn(async () => ({ ok: true as const, users: [] }))) + + await open() + await waitFor(() => expect(screen.getByText('No matching users.')).toBeTruthy()) + }) + + // A swallowed failure that renders as "no results" is what made a broken Jira + // connection look like an empty directory. + it('shows why the search failed instead of an empty list', async () => { + renderPicker( + vi.fn(async () => ({ + ok: false as const, + error: 'You do not have permission to browse users.' + })) + ) + + await open() + await waitFor(() => + expect( + screen.getByText("Couldn't search Jira users: You do not have permission to browse users.") + ).toBeTruthy() + ) + expect(screen.queryByText('No matching users.')).toBeNull() + }) + + it('never parks on a loading state after the search settles', async () => { + renderPicker(vi.fn(async () => ({ ok: false as const, error: 'Network unreachable' }))) + + await open() + await waitFor(() => expect(screen.getByText(/Network unreachable/)).toBeTruthy()) + expect(screen.queryByText('Searching users...')).toBeNull() + }) + + it('accepts a pasted account id the search never returned', async () => { + const searchUsers = vi.fn(async () => ({ ok: true as const, users: [] })) + const { onValueChange } = renderPicker(searchUsers) + + await open() + fireEvent.change(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), { + target: { value: '5b10a2844c20165700ede21g' } + }) + const useTyped = await screen.findByText( + 'Use account ID "5b10a2844c20165700ede21g"', + {}, + { timeout: 3000 } + ) + + fireEvent.click(useTyped) + expect(onValueChange).toHaveBeenCalledWith('5b10a2844c20165700ede21g') + }) + + it('does not offer the raw option when the search already returned that account', async () => { + renderPicker(vi.fn(async () => ({ ok: true as const, users: [ALEX] }))) + + await open() + fireEvent.change(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), { + target: { value: ALEX.accountId } + }) + await waitFor(() => expect(screen.getByText('Alex Rivera')).toBeTruthy()) + expect(screen.queryByText(`Use account ID "${ALEX.accountId}"`)).toBeNull() + }) + + it('labels the trigger with the picked person, not the raw id', async () => { + const onValueChange = vi.fn() + renderPicker( + vi.fn(async () => ({ ok: true as const, users: [ALEX] })), + '', + onValueChange + ) + + await open() + await waitFor(() => expect(screen.getByText('Alex Rivera')).toBeTruthy()) + fireEvent.click(screen.getByText('Alex Rivera')) + + cleanup() + render( + ({ ok: true as const, users: [ALEX] }))} + /> + ) + // Closed and never searched: the raw id is all it can honestly show. + expect(screen.getByRole('combobox', { name: 'Reporter' }).textContent).toContain(ALEX.accountId) + }) +}) diff --git a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx new file mode 100644 index 00000000000..6c7f579dd83 --- /dev/null +++ b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Check, ChevronDown } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { JiraUser, JiraUserSearchResult } from '../../../../../shared/jira-types' + +const SEARCH_DEBOUNCE_MS = 250 + +type SearchState = + | { status: 'loading' } + | { status: 'ready'; users: JiraUser[] } + | { status: 'error'; error: string } + +export function JiraUserFieldPicker({ + label, + value, + onValueChange, + searchUsers, + disabled +}: { + label: string + value: string + onValueChange: (value: string) => void + // Must be referentially stable: it re-triggers the search when it changes. + searchUsers: (query: string) => Promise + disabled?: boolean +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [search, setSearch] = useState({ status: 'ready', users: [] }) + const [pickedNames, setPickedNames] = useState>({}) + + useEffect(() => { + if (!open) { + return + } + let cancelled = false + setSearch({ status: 'loading' }) + const timer = setTimeout( + () => { + void searchUsers(query).then((result) => { + if (cancelled) { + return + } + setSearch( + result.ok + ? { status: 'ready', users: result.users } + : { status: 'error', error: result.error } + ) + }) + }, + query.trim() ? SEARCH_DEBOUNCE_MS : 0 + ) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [open, query, searchUsers]) + + const users = useMemo(() => (search.status === 'ready' ? search.users : []), [search]) + const trimmedQuery = query.trim() + // Jira accepts an accountId the search never surfaced (restricted directory, + // a value pasted from Jira itself), so a typed identifier stays selectable. + const showRawValueOption = + trimmedQuery.length > 0 && + search.status !== 'loading' && + !users.some((user) => user.accountId === trimmedQuery) + + const selectedLabel = useMemo(() => { + if (!value) { + return '' + } + return ( + users.find((user) => user.accountId === value)?.displayName ?? pickedNames[value] ?? value + ) + }, [pickedNames, users, value]) + + const handleSelect = useCallback( + (accountId: string, displayName?: string) => { + onValueChange(accountId) + if (displayName) { + setPickedNames((prev) => ({ ...prev, [accountId]: displayName })) + } + setOpen(false) + setQuery('') + }, + [onValueChange] + ) + + return ( + { + setOpen(next) + if (!next) { + setQuery('') + } + }} + > + + + + + + + + {search.status === 'loading' ? ( +
+ {translate( + 'auto.components.task.page.dialogs.jira.user.field.picker.searching', + 'Searching users...' + )} +
+ ) : null} + {search.status === 'error' ? ( +
+ {translate( + 'auto.components.task.page.dialogs.jira.user.field.picker.searchFailed', + "Couldn't search Jira users: {{value0}}", + { value0: search.error } + )} +
+ ) : null} + {search.status === 'ready' ? ( + + {translate( + 'auto.components.task.page.dialogs.jira.user.field.picker.noUsers', + 'No matching users.' + )} + + ) : null} + {users.map((user) => ( + handleSelect(user.accountId, user.displayName)} + className="items-center gap-2 px-3 py-2 text-xs" + > + + {user.displayName} + {user.email ? ( + + {user.email} + + ) : null} + + ))} + {showRawValueOption ? ( + handleSelect(trimmedQuery)} + className="items-center gap-2 px-3 py-2 text-xs" + > + + + {translate( + 'auto.components.task.page.dialogs.jira.user.field.picker.useTypedId', + 'Use account ID "{{value0}}"', + { value0: trimmedQuery } + )} + + + ) : null} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx new file mode 100644 index 00000000000..0c8c8ab5251 --- /dev/null +++ b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NewJiraIssueCustomFields } from './new-jira-issue-custom-fields' +import type { JiraCreateField } from '../../../../../shared/jira-types' + +afterEach(cleanup) + +const REPORTER: JiraCreateField = { + key: 'reporter', + name: 'Reporter', + required: true, + schema: { type: 'user' } +} + +const NOTES: JiraCreateField = { + key: 'customfield_9', + name: 'Notes', + required: true, + schema: { type: 'string' } +} + +function renderFields( + fields: JiraCreateField[], + searchUsers = vi.fn(async () => ({ ok: true as const, users: [] })) +) { + const setValues = vi.fn() + render( + + ) + return { setValues, searchUsers } +} + +describe('NewJiraIssueCustomFields', () => { + // Jira returns `reporter` with schema.type "user" and no allowedValues, which + // used to fall through to a free-text Input that could never resolve a user. + it('renders a searchable picker for a user field, not a text box', async () => { + const { searchUsers } = renderFields([REPORTER]) + + const trigger = screen.getByRole('combobox', { name: 'Reporter' }) + expect(screen.queryByRole('textbox', { name: 'Reporter' })).toBeNull() + + fireEvent.click(trigger) + await waitFor(() => expect(searchUsers).toHaveBeenCalledWith('')) + }) + + it('leaves non-user fields as their existing text input', () => { + renderFields([NOTES]) + + expect(screen.getByRole('textbox', { name: 'Notes' })).toBeTruthy() + }) + + it('renders nothing when there are no required fields', () => { + const { container } = render( + ({ ok: true as const, users: [] }))} + /> + ) + + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx index 5607a460485..c445a70771a 100644 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx +++ b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx @@ -8,20 +8,26 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { getJiraCreateAllowedValueLabel } from '@/components/task-page-jira-create-fields' +import { + getJiraCreateAllowedValueLabel, + isJiraUserCreateField +} from '@/components/task-page-jira-create-fields' +import { JiraUserFieldPicker } from './jira-user-field-picker' import { translate } from '@/i18n/i18n' -import type { JiraCreateField } from '../../../../../shared/jira-types' +import type { JiraCreateField, JiraUserSearchResult } from '../../../../../shared/jira-types' export function NewJiraIssueCustomFields({ visibleJiraCreateFields, newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues, - newJiraIssueSubmitting + newJiraIssueSubmitting, + searchJiraCreateUsers }: { visibleJiraCreateFields: JiraCreateField[] newJiraIssueCustomFieldValues: Record setNewJiraIssueCustomFieldValues: React.Dispatch>> newJiraIssueSubmitting: boolean + searchJiraCreateUsers: (query: string) => Promise }): React.JSX.Element | null { if (visibleJiraCreateFields.length === 0) { return null @@ -33,7 +39,20 @@ export function NewJiraIssueCustomFields({ return (
- {field.allowedValues?.length && field.schema?.type !== 'array' ? ( + {isJiraUserCreateField(field) ? ( + + setNewJiraIssueCustomFieldValues((prev) => ({ + ...prev, + [field.key]: value + })) + } + searchUsers={searchJiraCreateUsers} + disabled={newJiraIssueSubmitting} + /> + ) : field.allowedValues?.length && field.schema?.type !== 'array' ? ( Date: Fri, 28 Aug 2026 19:34:20 -0700 Subject: [PATCH 3/5] fix(jira): scope the user-field rewrite to declared fields and decode search rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review threads on #17090. The {accountId} marker is a shape, not a type, so createIssue rewrote any customFields value that merely looked like a user. The renderer already knows Jira's verdict — schema.type 'user', or an array of them — so it now sends that key list and the host rewrites only those keys. A key it was not told about is unknown, and unknown is left exactly as it arrived. normalizeJiraUserSearchResult cast each users entry to JiraUser without checking it, so a row with no accountId or displayName reached the picker, which keys and labels rows by both. Each entry is decoded now; one unreadable row fails the whole search into the unexpected-response path this PR already built, because a filtered list would look like the site's full answer. Moved the Jira IPC argument normalization into its own module to stay under the 300-line cap without a suppression. --- src/main/ipc/jira-ipc-arguments.ts | 47 ++++++++++++ src/main/ipc/jira.ts | 25 +------ .../jira/jira-create-reporter-payload.test.ts | 69 +++++++++++++++++- src/main/jira/jira-issue-mutations.ts | 10 ++- src/main/runtime/rpc/methods/jira.ts | 8 ++- src/preload/index.ts | 1 + .../task-page-jira-create-fields.test.ts | 21 ++++++ .../task-page-jira-create-fields.ts | 6 ++ .../hooks/use-task-page-create-jira-submit.ts | 9 ++- .../src/runtime/runtime-jira-client.test.ts | 72 ++++++++++++++++++- .../src/runtime/runtime-jira-user-search.ts | 68 +++++++++++++++--- src/shared/jira-types.ts | 4 ++ src/shared/jira-user-field-value.ts | 13 ++++ 13 files changed, 314 insertions(+), 39 deletions(-) create mode 100644 src/main/ipc/jira-ipc-arguments.ts diff --git a/src/main/ipc/jira-ipc-arguments.ts b/src/main/ipc/jira-ipc-arguments.ts new file mode 100644 index 00000000000..e94cfeac6db --- /dev/null +++ b/src/main/ipc/jira-ipc-arguments.ts @@ -0,0 +1,47 @@ +import type { JiraCreateIssueArgs } from '../../shared/jira-types' + +export function normalizeTrimmedArg(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +// IPC args are untrusted, so a malformed key list must read as "nothing declared" +// rather than reaching the resolver and widening what it rewrites. +function normalizeFieldKeyList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + const keys = value.filter( + (key): key is string => typeof key === 'string' && key.trim().length > 0 + ) + return keys.length > 0 ? keys : undefined +} + +export function normalizeJiraCreateIssueArgs( + args: JiraCreateIssueArgs +): { ok: true; args: JiraCreateIssueArgs } | { ok: false; error: string } { + const projectId = normalizeTrimmedArg(args?.projectId) + if (!projectId) { + return { ok: false, error: 'Project is required.' } + } + const issueTypeId = normalizeTrimmedArg(args.issueTypeId) + if (!issueTypeId) { + return { ok: false, error: 'Issue type is required.' } + } + const title = normalizeTrimmedArg(args.title) + if (!title) { + return { ok: false, error: 'Title is required.' } + } + return { + ok: true, + args: { + siteId: normalizeTrimmedArg(args.siteId), + projectId, + issueTypeId, + title, + description: args.description?.trim() || undefined, + customFields: + args.customFields && typeof args.customFields === 'object' ? args.customFields : undefined, + userFieldKeys: normalizeFieldKeyList(args.userFieldKeys) + } + } +} diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 92b959ccb61..a59b4923d39 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' import { _resetPreflightCache } from './preflight' import { JiraCancellableRequests } from './jira-cancellable-requests' +import { normalizeJiraCreateIssueArgs, normalizeTrimmedArg } from './jira-ipc-arguments' import { addIssueComment, createIssue, @@ -32,10 +33,6 @@ const VALID_FILTERS = new Set(['assigned', 'reported', 'all', ' const issueSummaryRequests = new JiraCancellableRequests() const searchRequests = new JiraCancellableRequests() -function normalizeTrimmedArg(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value.trim() : undefined -} - function normalizeSiteSelection(value: unknown): JiraSiteSelection | undefined { const siteId = normalizeTrimmedArg(value) return siteId as JiraSiteSelection | undefined @@ -191,24 +188,8 @@ export function registerJiraHandlers(): void { }) ipcMain.handle('jira:createIssue', async (_event, args: JiraCreateIssueArgs) => { - if (typeof args?.projectId !== 'string' || !args.projectId.trim()) { - return { ok: false, error: 'Project is required.' } - } - if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { - return { ok: false, error: 'Issue type is required.' } - } - if (typeof args?.title !== 'string' || !args.title.trim()) { - return { ok: false, error: 'Title is required.' } - } - return createIssue({ - siteId: normalizeTrimmedArg(args.siteId), - projectId: args.projectId.trim(), - issueTypeId: args.issueTypeId.trim(), - title: args.title.trim(), - description: args.description?.trim() || undefined, - customFields: - args.customFields && typeof args.customFields === 'object' ? args.customFields : undefined - }) + const normalized = normalizeJiraCreateIssueArgs(args) + return normalized.ok ? createIssue(normalized.args) : normalized }) ipcMain.handle( diff --git a/src/main/jira/jira-create-reporter-payload.test.ts b/src/main/jira/jira-create-reporter-payload.test.ts index ac006e95ff5..c78ea5f5a48 100644 --- a/src/main/jira/jira-create-reporter-payload.test.ts +++ b/src/main/jira/jira-create-reporter-payload.test.ts @@ -59,7 +59,10 @@ async function createWithReporter( title: 'Broken login', // Exactly the marker the create dialog builds from a picked user; the // renderer half of that seam is pinned in task-page-jira-create-fields.test. - customFields: { reporter: buildJiraUserFieldValue(draft) } + customFields: { reporter: buildJiraUserFieldValue(draft) }, + // The dialog derives this from Jira's create metadata, which declares + // reporter as schema.type 'user'. + userFieldKeys: ['reporter'] }) expect(result.ok).toBe(true) return postedFields() @@ -108,3 +111,67 @@ describe('Jira create reporter payload', () => { expect(fields.customfield_2).toBe('free text') }) }) + +// Thread 1: the {accountId} marker is structural, so without Jira's own verdict on +// which keys are user fields any lookalike object would be rewritten on its way out. +describe('Jira create user-field scoping', () => { + beforeEach(() => { + vi.clearAllMocks() + isAuthErrorMock.mockReturnValue(false) + jiraRequestMock.mockReset() + }) + + async function createWithFields( + customFields: Record, + userFieldKeys?: string[], + authType?: 'cloud' | 'server' + ): Promise> { + getClientsMock.mockReturnValue([entry(authType)]) + jiraRequestMock.mockResolvedValue({ id: '1', key: 'ENG-1', self: 'https://example' }) + const { createIssue } = await import('./jira-issue-mutations') + const result = await createIssue({ + projectId: '100', + issueTypeId: '10001', + title: 'Broken login', + customFields, + userFieldKeys + }) + expect(result.ok).toBe(true) + return postedFields() + } + + it('leaves an accountId-shaped value alone on a field Jira did not declare as a user field', async () => { + const fields = await createWithFields( + { reporter: { accountId: '5abc' }, customfield_1: { accountId: 'not-a-user' } }, + ['reporter'] + ) + + expect(fields.reporter).toEqual({ id: '5abc' }) + expect(fields.customfield_1).toEqual({ accountId: 'not-a-user' }) + }) + + it('leaves an accountId-shaped value alone when no field types were declared at all', async () => { + const fields = await createWithFields({ customfield_1: { accountId: 'not-a-user' } }) + + expect(fields.customfield_1).toEqual({ accountId: 'not-a-user' }) + }) + + it('keeps resolving every entry of a declared array-of-users field', async () => { + const fields = await createWithFields( + { customfield_2: [{ accountId: '5abc' }, { accountId: '5def' }] }, + ['customfield_2'] + ) + + expect(fields.customfield_2).toEqual([{ id: '5abc' }, { id: '5def' }]) + }) + + it('keeps resolving a declared array-of-users field for Server/DC', async () => { + const fields = await createWithFields( + { customfield_2: [{ accountId: 'ada' }, { accountId: 'grace' }] }, + ['customfield_2'], + 'server' + ) + + expect(fields.customfield_2).toEqual([{ name: 'ada' }, { name: 'grace' }]) + }) +}) diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts index 94c62b02704..ad27c061be9 100644 --- a/src/main/jira/jira-issue-mutations.ts +++ b/src/main/jira/jira-issue-mutations.ts @@ -7,7 +7,7 @@ import type { import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' -import { resolveJiraUserFieldValues } from '../../shared/jira-user-field-value' +import { resolveJiraCreateFieldValue } from '../../shared/jira-user-field-value' import { issueUrl, toBodyText } from './jira-issue-mapping' import type { JiraRecord } from './jira-record-pages' @@ -31,13 +31,19 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise( entry, diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 242231d2e90..39b47fb91ac 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -56,7 +56,10 @@ const CreateIssue = z.object({ issueTypeId: requiredString('Issue type is required'), title: requiredString('Title is required'), description: OptionalPlainString, - customFields: z.record(z.string(), z.unknown()).optional() + customFields: z.record(z.string(), z.unknown()).optional(), + // Optional so an older client that never sends it still decodes; the host then + // declares nothing a user field and rewrites nothing. + userFieldKeys: z.array(z.string()).optional() }) const IssueUpdate = z.object({ @@ -202,7 +205,8 @@ export const JIRA_METHODS: RpcAnyMethod[] = [ issueTypeId: params.issueTypeId.trim(), title: params.title.trim(), description: params.description?.trim() || undefined, - customFields: params.customFields + customFields: params.customFields, + userFieldKeys: params.userFieldKeys }) }), defineMethod({ diff --git a/src/preload/index.ts b/src/preload/index.ts index f9374319da5..20af538ac0b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2072,6 +2072,7 @@ const api = { title: string description?: string customFields?: Record + userFieldKeys?: string[] }): Promise< { ok: true; id: string; key: string; url: string } | { ok: false; error: string } > => ipcRenderer.invoke('jira:createIssue', args), diff --git a/src/renderer/src/components/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page-jira-create-fields.test.ts index f54713c46de..d909e85fe72 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.test.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.test.ts @@ -6,6 +6,7 @@ import { findJiraCreateAllowedValue, getJiraCreateAllowedValueLabel, getJiraCreateOptionPayload, + getJiraUserCreateFieldKeys, isJiraUserCreateField, isVisibleJiraCreateField } from './task-page-jira-create-fields' @@ -233,3 +234,23 @@ describe('buildJiraCreateFieldValue for user fields', () => { }) }) }) + +// The host rewrites the {accountId} marker only for the keys named here, so this +// list is the whole reason a lookalike value on another field survives untouched. +describe('getJiraUserCreateFieldKeys', () => { + it('names only the fields Jira declares as users, single and array alike', () => { + expect( + getJiraUserCreateFieldKeys([ + field({ key: 'reporter', schema: { type: 'user' } }), + field({ key: 'customfield_watchers', schema: { type: 'array', items: 'user' } }), + field({ key: 'customfield_opt', schema: { type: 'option' } }), + field({ key: 'customfield_text', schema: { type: 'string' } }), + field({ key: 'customfield_untyped' }) + ]) + ).toEqual(['reporter', 'customfield_watchers']) + }) + + it('names nothing when the issue type has no user field', () => { + expect(getJiraUserCreateFieldKeys([field({ key: 'customfield_opt' })])).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page-jira-create-fields.ts index 3f173ae003c..74826b9c454 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -14,6 +14,12 @@ export function isJiraUserCreateField(field: JiraCreateField): boolean { return field.schema?.type === 'user' || field.schema?.items === 'user' } +// The host's {accountId} rewrite is shape-based, so it needs Jira's verdict on +// which keys are user fields rather than inferring it from the value. +export function getJiraUserCreateFieldKeys(fields: readonly JiraCreateField[]): string[] { + return fields.filter(isJiraUserCreateField).map((field) => field.key) +} + export function getJiraCreateAllowedValueLabel( value: NonNullable[number] ): string { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-create-jira-submit.ts b/src/renderer/src/components/task-page/hooks/use-task-page-create-jira-submit.ts index 865d98642c5..502c0003fe7 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-create-jira-submit.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-create-jira-submit.ts @@ -3,7 +3,10 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { jiraCreateIssue, jiraGetIssue } from '@/runtime/runtime-jira-client' -import { buildJiraCreateCustomFields } from '@/components/task-page-jira-create-fields' +import { + buildJiraCreateCustomFields, + getJiraUserCreateFieldKeys +} from '@/components/task-page-jira-create-fields' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { JiraCreateField, @@ -72,6 +75,7 @@ export function useTaskPageCreateJiraSubmit({ visibleJiraCreateFields, newJiraIssueCustomFieldValues ) + const userFieldKeys = getJiraUserCreateFieldKeys(visibleJiraCreateFields) setNewJiraIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { @@ -81,7 +85,8 @@ export function useTaskPageCreateJiraSubmit({ issueTypeId: newJiraIssueTargetType.id, title, description: newJiraIssueBody || undefined, - customFields + customFields, + userFieldKeys }) if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return diff --git a/src/renderer/src/runtime/runtime-jira-client.test.ts b/src/renderer/src/runtime/runtime-jira-client.test.ts index 173c514545d..5a10b908ea1 100644 --- a/src/renderer/src/runtime/runtime-jira-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-client.test.ts @@ -230,11 +230,14 @@ describe('runtime Jira client search bounds', () => { describe('jiraSearchUsers', () => { it('passes the project scope straight through on a local host', async () => { - jiraSearchUsersLocal.mockResolvedValue({ ok: true, users: [{ accountId: '5abc' }] }) + jiraSearchUsersLocal.mockResolvedValue({ + ok: true, + users: [{ accountId: '5abc', displayName: 'Alex Doe' }] + }) await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG', query: 'Alex' })).resolves.toEqual({ ok: true, - users: [{ accountId: '5abc' }] + users: [{ accountId: '5abc', displayName: 'Alex Doe' }] }) expect(jiraSearchUsersLocal).toHaveBeenCalledWith({ projectIdOrKey: 'ENG', query: 'Alex' }) }) @@ -269,6 +272,71 @@ describe('jiraSearchUsers', () => { } }) + // Thread 2: every entry is cast, not checked. The picker keys rows by accountId + // and labels them by displayName, so an entry missing either is an unusable row. + const malformedEntries: [string, unknown][] = [ + ['no accountId', { displayName: 'Alex Doe' }], + ['a blank accountId', { accountId: ' ', displayName: 'Alex Doe' }], + ['a non-string accountId', { accountId: 7, displayName: 'Alex Doe' }], + ['no displayName', { accountId: '5abc' }], + ['a blank displayName', { accountId: '5abc', displayName: ' ' }], + ['a non-string displayName', { accountId: '5abc', displayName: 7 }], + ['a non-string email', { accountId: '5abc', displayName: 'Alex Doe', email: 7 }], + ['a non-string avatarUrl', { accountId: '5abc', displayName: 'Alex Doe', avatarUrl: 7 }], + ['a non-object entry', 'Alex Doe'], + ['a null entry', null] + ] + for (const [label, entry] of malformedEntries) { + it(`fails the search when an entry has ${label}`, async () => { + jiraSearchUsersLocal.mockResolvedValue({ + ok: true, + users: [{ accountId: '5abc', displayName: 'Alex Doe' }, entry] + }) + + await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({ + ok: false, + error: 'Jira user search returned an unexpected response.' + }) + }) + } + + it('keeps a fully-formed list, including the optional fields Jira may omit or null out', async () => { + jiraSearchUsersLocal.mockResolvedValue({ + ok: true, + users: [ + { accountId: '5abc', displayName: 'Alex Doe', email: null }, + { + accountId: 'ada', + displayName: 'Ada L', + email: 'ada@x.test', + avatarUrl: 'https://a/x.png' + } + ] + }) + + await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({ + ok: true, + users: [ + { accountId: '5abc', displayName: 'Alex Doe', email: null }, + { + accountId: 'ada', + displayName: 'Ada L', + email: 'ada@x.test', + avatarUrl: 'https://a/x.png' + } + ] + }) + }) + + it('keeps an empty list reading as a successful empty search', async () => { + jiraSearchUsersLocal.mockResolvedValue({ ok: true, users: [] }) + + await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({ + ok: true, + users: [] + }) + }) + it('rejects an oversized query before it reaches the host', async () => { await expect( jiraSearchUsers( diff --git a/src/renderer/src/runtime/runtime-jira-user-search.ts b/src/renderer/src/runtime/runtime-jira-user-search.ts index 87b6a1cfe90..75a920438d6 100644 --- a/src/renderer/src/runtime/runtime-jira-user-search.ts +++ b/src/renderer/src/runtime/runtime-jira-user-search.ts @@ -4,6 +4,52 @@ import { callRuntimeRpc } from './runtime-rpc-client' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target' +function unexpectedJiraUserSearchResponse(): JiraUserSearchResult { + return { + ok: false, + error: translate( + 'auto.runtime.runtime.jira.user.search.unexpectedResponse', + 'Jira user search returned an unexpected response.' + ) + } +} + +function nonBlankString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +// The picker keys rows by accountId and labels them by displayName, so an entry +// missing either is a row that renders blank or selects nothing. The rows come +// from a Jira site we do not control, across Cloud and Server/DC and across +// versions, so each one is checked rather than cast. +function decodeJiraUser(value: unknown): JiraUser | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined + } + const candidate = value as { [K in keyof JiraUser]?: unknown } + const accountId = nonBlankString(candidate.accountId) + const displayName = nonBlankString(candidate.displayName) + if (!accountId || !displayName) { + return undefined + } + // Jira omits these or sends email as null when the directory hides it; any + // other shape means we are not reading the payload we think we are. + if (candidate.email !== undefined && candidate.email !== null) { + if (typeof candidate.email !== 'string') { + return undefined + } + } + if (candidate.avatarUrl !== undefined && typeof candidate.avatarUrl !== 'string') { + return undefined + } + return { + accountId, + displayName, + email: candidate.email as string | null | undefined, + avatarUrl: candidate.avatarUrl as string | undefined + } +} + // Runtime RPC results are cast, never decoded, so an unrecognized payload (an // older host, a shape change) must read as a failed search rather than an empty // one — an empty dropdown is what this picker exists to stop lying about. @@ -11,19 +57,25 @@ function normalizeJiraUserSearchResult(value: unknown): JiraUserSearchResult { if (typeof value === 'object' && value !== null) { const candidate = value as { ok?: unknown; users?: unknown; error?: unknown } if (candidate.ok === true && Array.isArray(candidate.users)) { - return { ok: true, users: candidate.users as JiraUser[] } + const users: JiraUser[] = [] + for (const entry of candidate.users) { + const user = decodeJiraUser(entry) + // One unreadable row fails the whole search. Filtering would hand back a + // short list that looks like the site's full answer, which is the same + // lie as the empty dropdown, and the error path still lets the user paste + // an account id the search never surfaced. + if (!user) { + return unexpectedJiraUserSearchResponse() + } + users.push(user) + } + return { ok: true, users } } if (candidate.ok === false && typeof candidate.error === 'string' && candidate.error) { return { ok: false, error: candidate.error } } } - return { - ok: false, - error: translate( - 'auto.runtime.runtime.jira.user.search.unexpectedResponse', - 'Jira user search returned an unexpected response.' - ) - } + return unexpectedJiraUserSearchResponse() } export async function jiraSearchUsers( diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index e29ebdd2e9f..602255c334c 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -164,6 +164,10 @@ export type JiraCreateIssueArgs = { title: string description?: string customFields?: Record + // Keys Jira's own create metadata declares as user fields (schema.type 'user', + // or an array of them). The {accountId} marker is only a shape, so the host + // rewrites these keys and nothing else; an undeclared key is left as it arrived. + userFieldKeys?: string[] } export type JiraCreateIssueResult = diff --git a/src/shared/jira-user-field-value.ts b/src/shared/jira-user-field-value.ts index d779b9d3155..f57606ba473 100644 --- a/src/shared/jira-user-field-value.ts +++ b/src/shared/jira-user-field-value.ts @@ -31,6 +31,19 @@ export function resolveJiraUserFieldValue( return authType === 'server' ? { name: accountId } : { id: accountId } } +// Only a field Jira declared as a user field is rewritten. The marker is a shape, +// not a type, so a lookalike object on an undeclared key would otherwise be +// silently retyped on its way to Jira; undeclared means unknown, and unknown is +// left exactly as it arrived. +export function resolveJiraCreateFieldValue( + fieldKey: string, + value: unknown, + userFieldKeys: ReadonlySet, + authType: JiraAuthType | undefined +): unknown { + return userFieldKeys.has(fieldKey) ? resolveJiraUserFieldValues(value, authType) : value +} + // Leaves every non-user value untouched so other providers' and Jira's own // option/number/text fields keep flowing through unchanged. export function resolveJiraUserFieldValues( From 93defc2ca5425acaf2fde1473cdc081755f25f3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:29:33 -0700 Subject: [PATCH 4/5] fix(jira): keep multi-user picker open while selecting --- .../task-page/dialogs/jira-user-field-picker.test.tsx | 2 +- .../components/task-page/dialogs/jira-user-field-picker.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx index 97a9065a1bb..c384786a092 100644 --- a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx +++ b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.test.tsx @@ -159,7 +159,7 @@ describe('JiraUserFieldPicker', () => { await waitFor(() => expect(screen.getByText(ALEX.displayName)).toBeTruthy()) fireEvent.click(screen.getByText(ALEX.displayName)) - fireEvent.click(screen.getByRole('combobox', { name: 'Participants' })) + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeTruthy() await waitFor(() => expect(screen.getByText(BLAIR.displayName)).toBeTruthy()) fireEvent.click(screen.getByText(BLAIR.displayName)) diff --git a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx index 414befb45b9..9a8ff200570 100644 --- a/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx +++ b/src/renderer/src/components/task-page/dialogs/jira-user-field-picker.tsx @@ -115,8 +115,10 @@ export function JiraUserFieldPicker({ if (displayName) { setPickedNames((prev) => ({ ...prev, [accountId]: displayName })) } - setOpen(false) - setQuery('') + if (!multiple) { + setOpen(false) + setQuery('') + } }, [multiple, onValueChange, selectedAccountIds] ) From c030475a13e498661f9f52df8a3ae8ce4254cf22 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:30:48 -0700 Subject: [PATCH 5/5] test(jira): keep multi-user selection in one popover --- .../task-page/dialogs/new-jira-issue-custom-fields.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx index bcea0463338..a09c315aae5 100644 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx +++ b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.test.tsx @@ -90,7 +90,7 @@ describe('NewJiraIssueCustomFields', () => { fireEvent.click(screen.getByRole('combobox', { name: 'Participants' })) await waitFor(() => expect(screen.getByText(alex.displayName)).toBeTruthy()) fireEvent.click(screen.getByText(alex.displayName)) - fireEvent.click(screen.getByRole('combobox', { name: 'Participants' })) + expect(screen.getByPlaceholderText('Search users or paste an account ID...')).toBeTruthy() await waitFor(() => expect(screen.getByText(blair.displayName)).toBeTruthy()) fireEvent.click(screen.getByText(blair.displayName))