From 3ffbaf2e61061c2c68e4cad0c5d28d60108008eb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:42:03 -0700 Subject: [PATCH] fix(jira): support user-picker fields in create-issue dialog Jira's create-issue dialog rendered user fields (e.g. Reporter, schema type "user") as a plain text Input and submitted the raw string. Jira Cloud v3 requires the { id: accountId } object shape (display names and usernames were removed in the 2019 GDPR change), so any required user field rejected the create with "Reporter is required". This adds schema-driven user-picker support end to end: - buildJiraCreateFieldValue now maps user-picker drafts to { id } (single) or [{ id }, ...] (multi-user array), extracted alongside the rest of the create-field value logic into jira-create-field-value.ts so it is unit testable. - A searchable JiraCreateUserPicker combobox replaces the text Input for user fields, backed by a new project-scoped search (listAssignableUsersForCreate -> /user/assignable/multiProjectSearch with a /user/search fallback) wired through IPC, the runtime RPC registry, and the runtime-jira-client wrapper so it works over SSH/remote runtimes. - Required user fields default to the connected site's token owner so the dialog submits with zero interaction. Detection is generic (schema type/items/custom), so assignee and custom user-pickers work too, not just Reporter. Fixes #4643 --- src/main/ipc/jira.ts | 15 ++ src/main/jira/issues.test.ts | 45 ++++ src/main/jira/issues.ts | 53 +++++ src/main/runtime/orca-runtime.ts | 9 + src/main/runtime/rpc/methods/jira.test.ts | 9 + src/main/runtime/rpc/methods/jira.ts | 16 ++ src/preload/api-types.ts | 5 + src/preload/index.ts | 6 + src/renderer/src/components/TaskPage.tsx | 147 ++++++------ .../jira-create-field-value.test.ts | 101 ++++++++ .../src/components/jira-create-field-value.ts | 119 ++++++++++ .../components/jira-create-user-picker.tsx | 219 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 10 + src/renderer/src/i18n/locales/es.json | 10 + src/renderer/src/i18n/locales/ja.json | 10 + src/renderer/src/i18n/locales/ko.json | 10 + src/renderer/src/i18n/locales/zh.json | 10 + .../src/runtime/runtime-jira-client.test.ts | 72 +++++- .../src/runtime/runtime-jira-client.ts | 18 ++ 19 files changed, 800 insertions(+), 84 deletions(-) create mode 100644 src/renderer/src/components/jira-create-field-value.test.ts create mode 100644 src/renderer/src/components/jira-create-field-value.ts create mode 100644 src/renderer/src/components/jira-create-user-picker.tsx diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e8ed17f9bfe..036a26becb3 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -7,6 +7,7 @@ import { getIssue, getIssueComments, listAssignableUsers, + listAssignableUsersForCreate, listCreateFields, listIssueTypes, listIssues, @@ -253,6 +254,20 @@ export function registerJiraHandlers(): void { } ) + ipcMain.handle( + 'jira:listAssignableUsersForCreate', + async (_event, args: { projectKeyOrId: string; query?: string; siteId?: string }) => { + if (typeof args?.projectKeyOrId !== 'string' || !args.projectKeyOrId.trim()) { + return [] + } + return listAssignableUsersForCreate( + args.projectKeyOrId.trim(), + typeof args.query === 'string' ? args.query : undefined, + normalizeSiteId(args.siteId) + ) + } + ) + ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => { if (typeof args?.key !== 'string' || !args.key.trim()) { return [] diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 49a49a46076..dee7423dcb4 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -365,6 +365,51 @@ describe('Jira issue operations', () => { ) }) + it('lists project-scoped assignable users for create via multiProjectSearch', async () => { + jiraRequestMock.mockResolvedValueOnce([ + { accountId: '5b10ac', displayName: 'Alex' }, + { displayName: 'No account id' } + ]) + + const { listAssignableUsersForCreate } = await import('./issues') + + await expect(listAssignableUsersForCreate('ORCA', 'al', 'site-1')).resolves.toEqual([ + { accountId: '5b10ac', displayName: 'Alex', email: undefined, avatarUrl: undefined } + ]) + + const path = String(jiraRequestMock.mock.calls[0][1]) + expect(path).toContain('/rest/api/3/user/assignable/multiProjectSearch?') + expect(path).toContain('projectKeys=ORCA') + expect(path).toContain('query=al') + }) + + it('falls back to /user/search when multiProjectSearch is unavailable (404)', async () => { + jiraRequestMock + .mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 })) + .mockResolvedValueOnce([{ accountId: '5b10ac', displayName: 'Alex' }]) + + const { listAssignableUsersForCreate } = await import('./issues') + + await expect(listAssignableUsersForCreate('ORCA', 'al', 'site-1')).resolves.toEqual([ + { accountId: '5b10ac', displayName: 'Alex', email: undefined, avatarUrl: undefined } + ]) + + expect(String(jiraRequestMock.mock.calls[1][1])).toContain('/rest/api/3/user/search?') + }) + + it('clears the token and rethrows when create assignable search hits an auth error', async () => { + const authError = Object.assign(new Error('Unauthorized'), { status: 401 }) + isAuthErrorMock.mockImplementation((error) => error === authError) + jiraRequestMock.mockRejectedValueOnce(authError) + + const { listAssignableUsersForCreate } = await import('./issues') + + await expect(listAssignableUsersForCreate('ORCA', undefined, 'site-1')).rejects.toThrow( + 'Unauthorized' + ) + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + it('maps comments from the Jira comments page key', async () => { jiraRequestMock.mockResolvedValueOnce({ comments: [ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 3bf8237349c..890d70737f0 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -780,6 +780,59 @@ export async function listAssignableUsers( } } +export async function listAssignableUsersForCreate( + projectKeyOrId: string, + query?: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + const trimmedQuery = query?.trim() ?? '' + await acquire() + try { + // Why: the assignable/search endpoint needs an existing issueKey, which does + // not exist during create; multiProjectSearch is the project-scoped variant. + const multiProjectParams = new URLSearchParams({ + projectKeys: projectKeyOrId, + maxResults: '50' + }) + if (trimmedQuery) { + multiProjectParams.set('query', trimmedQuery) + } + try { + const response = await jiraRequest( + entry, + `/rest/api/3/user/assignable/multiProjectSearch?${multiProjectParams.toString()}` + ) + return response.map(mapUser).filter((user): user is JiraUser => !!user) + } catch (error) { + // Why: some deployments (older/self-managed) lack multiProjectSearch; fall + // back to the general user search so the picker still resolves accountIds. + if (getErrorStatus(error) !== 404) { + throw error + } + const fallbackParams = new URLSearchParams({ maxResults: '50' }) + fallbackParams.set('query', trimmedQuery) + const response = await jiraRequest( + entry, + `/rest/api/3/user/search?${fallbackParams.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] listAssignableUsersForCreate failed:', error) + return [] + } finally { + release() + } +} + export async function listTransitions( key: string, siteId?: string | null diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4c421345834..91b98c11a5e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -495,6 +495,7 @@ import { getIssue as getJiraIssue, getIssueComments as getJiraIssueComments, listAssignableUsers as listJiraAssignableUsers, + listAssignableUsersForCreate as listJiraAssignableUsersForCreate, listCreateFields as listJiraCreateFields, listIssueTypes as listJiraIssueTypes, listIssues as listJiraIssues, @@ -20878,6 +20879,14 @@ export class OrcaRuntimeService { return listJiraAssignableUsers(key, query, siteId) } + jiraListAssignableUsersForCreate( + projectKeyOrId: string, + query?: string, + siteId?: string + ): ReturnType { + return listJiraAssignableUsersForCreate(projectKeyOrId, 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 8df96326c0a..294086e2b99 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -125,6 +125,7 @@ describe('jira RPC methods', () => { jiraListCreateFields: vi.fn().mockResolvedValue([{ key: 'customfield_10010' }]), jiraListPriorities: vi.fn().mockResolvedValue([{ id: 'priority-1' }]), jiraListAssignableUsers: vi.fn().mockResolvedValue([{ accountId: 'user-1' }]), + jiraListAssignableUsersForCreate: vi.fn().mockResolvedValue([{ accountId: 'user-2' }]), jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) @@ -148,6 +149,13 @@ describe('jira RPC methods', () => { siteId: 'site-1' }) ) + await dispatcher.dispatch( + makeRequest('jira.listAssignableUsersForCreate', { + projectKeyOrId: 'ORCA', + query: 'Ada', + siteId: 'site-1' + }) + ) await dispatcher.dispatch( makeRequest('jira.listTransitions', { key: 'ABC-3', siteId: 'site-1' }) ) @@ -157,6 +165,7 @@ describe('jira RPC methods', () => { expect(runtime.jiraListCreateFields).toHaveBeenCalledWith('project-1', 'type-1', 'site-1') expect(runtime.jiraListPriorities).toHaveBeenCalledWith('site-1') expect(runtime.jiraListAssignableUsers).toHaveBeenCalledWith('ABC-3', 'Ada', 'site-1') + expect(runtime.jiraListAssignableUsersForCreate).toHaveBeenCalledWith('ORCA', 'Ada', 'site-1') expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1') }) }) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 9a4c614d9ee..6a04fcc00d4 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -88,6 +88,12 @@ const AssignableUsers = z.object({ siteId: OptionalString }) +const AssignableUsersForCreate = z.object({ + projectKeyOrId: requiredString('Project is required'), + query: OptionalPlainString, + siteId: OptionalString +}) + export const JIRA_METHODS: RpcMethod[] = [ defineMethod({ name: 'jira.connect', @@ -199,6 +205,16 @@ export const JIRA_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId) }), + defineMethod({ + name: 'jira.listAssignableUsersForCreate', + params: AssignableUsersForCreate, + handler: async (params, { runtime }) => + runtime.jiraListAssignableUsersForCreate( + params.projectKeyOrId.trim(), + params.query, + params.siteId + ) + }), defineMethod({ name: 'jira.listTransitions', params: IssueKey, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7ae75a59aa3..93817db9790 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1725,6 +1725,11 @@ export type PreloadApi = { query?: string siteId?: string }) => Promise + listAssignableUsersForCreate: (args: { + projectKeyOrId: string + query?: string + siteId?: string + }) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise } starNag: { diff --git a/src/preload/index.ts b/src/preload/index.ts index a3adca161ba..6370a921866 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1535,6 +1535,12 @@ const api = { siteId?: string }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + listAssignableUsersForCreate: (args: { + projectKeyOrId: string + query?: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listAssignableUsersForCreate', args), + listTransitions: (args: { key: string; siteId?: string }): Promise => ipcRenderer.invoke('jira:listTransitions', args) }, diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8e0bc6e29fd..a031072b9d5 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -90,6 +90,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import JiraCreateUserPicker from '@/components/jira-create-user-picker' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -233,7 +234,13 @@ import { } from '@/components/task-page-jira-load-state' import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' +import { + buildJiraCreateCustomFields, + getJiraCreateAllowedValueLabel, + isJiraUserPickerField, + isMultiJiraUserPickerField, + isVisibleJiraCreateField +} from '@/components/jira-create-field-value' import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods @@ -253,6 +260,7 @@ import type { JiraIssue, JiraIssueType, JiraProject, + JiraUser, LinearIssue, LinearProjectDetail, LinearProjectSummary, @@ -945,84 +953,6 @@ function compareJiraProjectsByDisplayLabel( return jiraProjectLabelCollator.compare(a.key, b.key) } -const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description']) - -function isVisibleJiraCreateField(field: JiraCreateField): boolean { - return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key) -} - -function getJiraCreateAllowedValueLabel( - value: NonNullable[number] -): string { - return value.name ?? value.value ?? value.id ?? 'Option' -} - -function findJiraCreateAllowedValue(field: JiraCreateField, draftValue: string) { - return field.allowedValues?.find((value) => { - return value.id === draftValue || value.value === draftValue || value.name === draftValue - }) -} - -function getJiraCreateOptionPayload( - value: NonNullable[number] | undefined, - fallback: string -): Record | string { - if (value?.id) { - return { id: value.id } - } - if (value?.value) { - return { value: value.value } - } - if (value?.name) { - return { name: value.name } - } - return fallback -} - -function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown { - const trimmed = draftValue.trim() - if (!trimmed) { - return undefined - } - if (field.schema?.type === 'array') { - const parts = trimmed - .split(',') - .map((part) => part.trim()) - .filter(Boolean) - if (field.allowedValues?.length) { - return parts.map((part) => - getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, part), part) - ) - } - return parts - } - if (field.allowedValues?.length) { - return getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, trimmed), trimmed) - } - if (field.schema?.type === 'number') { - const numberValue = Number(trimmed) - return Number.isFinite(numberValue) ? numberValue : trimmed - } - if (field.schema?.custom?.includes(':textarea') || field.schema?.type === 'textarea') { - return buildJiraCreateTextAdf(trimmed) - } - return trimmed -} - -function buildJiraCreateCustomFields( - fields: readonly JiraCreateField[], - values: Record -): Record | undefined { - const customFields: Record = {} - for (const field of fields) { - const value = buildJiraCreateFieldValue(field, values[field.key] ?? '') - if (value !== undefined) { - customFields[field.key] = value - } - } - return Object.keys(customFields).length > 0 ? customFields : undefined -} - function GHStatusCell({ item, repo, @@ -5704,6 +5634,46 @@ export default function TaskPage(): React.JSX.Element { [jiraCreateFields] ) + // Why: each JiraSite carries the API-token owner's accountId; used to default + // required user-picker fields (e.g. Reporter) so create works with no input. + const newJiraIssueTokenOwner = useMemo(() => { + const sites = jiraStatus.sites ?? [] + const projectSiteId = newJiraIssueTargetProject?.siteId + const ownerSite = + (projectSiteId ? sites.find((site) => site.id === projectSiteId) : null) ?? + selectedJiraSite ?? + (sites.length === 1 ? sites[0] : null) + if (!ownerSite?.accountId) { + return null + } + return { accountId: ownerSite.accountId, displayName: ownerSite.displayName } + }, [jiraStatus.sites, newJiraIssueTargetProject, selectedJiraSite]) + + const newJiraIssueTokenOwnerKnownUsers = useMemo( + () => (newJiraIssueTokenOwner ? [newJiraIssueTokenOwner] : []), + [newJiraIssueTokenOwner] + ) + + // Prefill empty required user-picker fields with the token owner so a required + // Reporter resolves to a valid accountId without any user interaction. + useEffect(() => { + const ownerAccountId = newJiraIssueTokenOwner?.accountId + if (!ownerAccountId || visibleJiraCreateFields.length === 0) { + return + } + setNewJiraIssueCustomFieldValues((prev) => { + let changed = false + const next = { ...prev } + for (const field of visibleJiraCreateFields) { + if (isJiraUserPickerField(field) && !(prev[field.key] ?? '').trim()) { + next[field.key] = ownerAccountId + changed = true + } + } + return changed ? next : prev + }) + }, [newJiraIssueTokenOwner, visibleJiraCreateFields]) + const hasMissingJiraCreateField = useMemo( () => visibleJiraCreateFields.some( @@ -12336,7 +12306,24 @@ export default function TaskPage(): React.JSX.Element { - {field.allowedValues?.length && field.schema?.type !== 'array' ? ( + {isJiraUserPickerField(field) ? ( + + setNewJiraIssueCustomFieldValues((prev) => ({ + ...prev, + [field.key]: value + })) + } + multiple={isMultiJiraUserPickerField(field)} + projectKeyOrId={newJiraIssueTargetProject?.key ?? ''} + siteId={newJiraIssueTargetProject?.siteId} + runtimeSettings={jiraTaskSourceContext ?? settings} + knownUsers={newJiraIssueTokenOwnerKnownUsers} + disabled={newJiraIssueSubmitting} + /> + ) : field.allowedValues?.length && field.schema?.type !== 'array' ? (