sim: merge PR #17090

This commit is contained in:
Brennan Benson
2026-08-30 15:41:27 -07:00
30 changed files with 1784 additions and 105 deletions
+47
View File
@@ -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)
}
}
}
+34 -35
View File
@@ -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,
@@ -16,6 +17,7 @@ import {
listPriorities,
listProjects,
listTransitions,
searchAssignableUsers,
searchIssues,
updateIssue
} from '../jira/issues'
@@ -31,12 +33,8 @@ const VALID_FILTERS = new Set<JiraIssueFilter>(['assigned', 'reported', 'all', '
const issueSummaryRequests = new JiraCancellableRequests()
const searchRequests = new JiraCancellableRequests()
function normalizeSiteId(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 +103,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 +124,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 +163,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(
@@ -190,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: normalizeSiteId(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(
@@ -220,7 +202,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 +215,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 +223,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 +236,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 +252,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 +270,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 +296,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 +305,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))
}
)
}
+2 -6
View File
@@ -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'
@@ -0,0 +1,177 @@
// 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<string, unknown> {
const init = jiraRequestMock.mock.calls[0]?.[2] as { body?: string } | undefined
return (
(JSON.parse(String(init?.body ?? '{}')) as { fields?: Record<string, unknown> }).fields ?? {}
)
}
async function createWithReporter(
draft: string,
authType?: 'cloud' | 'server'
): Promise<Record<string, unknown>> {
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) },
// 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()
}
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')
})
})
// 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<string, unknown>,
userFieldKeys?: string[],
authType?: 'cloud' | 'server'
): Promise<Record<string, unknown>> {
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' }])
})
})
+2 -42
View File
@@ -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<JiraPriori
release()
}
}
export async function listAssignableUsers(
key: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
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<JiraRecord[]>(
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()
}
}
+10 -1
View File
@@ -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 { resolveJiraCreateFieldValue } from '../../shared/jira-user-field-value'
import { issueUrl, toBodyText } from './jira-issue-mapping'
import type { JiraRecord } from './jira-record-pages'
@@ -30,11 +31,19 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
if (args.description?.trim()) {
fields.description = toBodyText(entry.site, args.description.trim())
}
const userFieldKeys = new Set(args.userFieldKeys ?? [])
for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) {
if (!fieldKey || value === undefined || value === null || value === '') {
continue
}
fields[fieldKey] = value
// User fields arrive as the provider-neutral {accountId} marker; only the
// host knows whether this site wants Cloud {id} or Server/DC {name}.
fields[fieldKey] = resolveJiraCreateFieldValue(
fieldKey,
value,
userFieldKeys,
entry.site.authType
)
}
const created = await jiraRequest<{ id: string; key: string; self: string }>(
entry,
+193
View File
@@ -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')
})
})
+81
View File
@@ -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<JiraUserSearchResult> {
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<JiraRecord[]>(
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<JiraUser[]> {
const result = await searchAssignableUsers({ issueKey: key }, query, siteId)
return result.ok ? result.users : []
}
+9
View File
@@ -1085,6 +1085,7 @@ import {
getIssueComments as getJiraIssueComments,
getProjectStatusOrder as getJiraProjectStatusOrder,
listAssignableUsers as listJiraAssignableUsers,
searchAssignableUsers as searchJiraAssignableUsers,
listCreateFields as listJiraCreateFields,
listIssueTypes as listJiraIssueTypes,
listIssues as listJiraIssues,
@@ -40840,6 +40841,14 @@ export class OrcaRuntimeService {
return listJiraAssignableUsers(key, query, siteId)
}
jiraSearchUsers(
scope: { issueKey?: string; projectIdOrKey?: string },
query?: string,
siteId?: string
): ReturnType<typeof searchJiraAssignableUsers> {
return searchJiraAssignableUsers(scope, query, siteId)
}
jiraListTransitions(key: string, siteId?: string): ReturnType<typeof listJiraTransitions> {
return listJiraTransitions(key, siteId)
}
+39
View File
@@ -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
)
})
})
+25 -2
View File
@@ -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({
@@ -94,6 +97,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
@@ -193,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({
@@ -253,6 +266,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,
+3
View File
@@ -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<JiraUser[]>
searchUsers: (args: JiraUserSearchArgs) => Promise<JiraUserSearchResult>
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
getProjectStatusOrder: (args: {
projectKey: string
+8
View File
@@ -2078,6 +2078,7 @@ const api = {
title: string
description?: string
customFields?: Record<string, unknown>
userFieldKeys?: string[]
}): Promise<
{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }
> => ipcRenderer.invoke('jira:createIssue', args),
@@ -2120,6 +2121,13 @@ const api = {
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listAssignableUsers', args),
searchUsers: (args: {
projectIdOrKey?: string
issueKey?: string
query?: string
siteId?: string
}): Promise<unknown> => ipcRenderer.invoke('jira:searchUsers', args),
listTransitions: (args: { key: string; siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listTransitions', args),
getProjectStatusOrder: (args: {
+5 -1
View File
@@ -1351,6 +1351,8 @@ export default function TaskPage(): React.JSX.Element {
newJiraIssueTargetType,
visibleJiraCreateFields,
hasMissingJiraCreateField,
missingJiraCreateFieldNames,
searchJiraCreateUsers,
handleNewJiraIssueProjectComboboxOpenChange,
handleNewJiraIssueProjectSelect,
handleNewJiraIssueProjectTriggerKeyDown
@@ -3042,7 +3044,9 @@ export default function TaskPage(): React.JSX.Element {
newJiraIssueCustomFieldValues,
setNewJiraIssueCustomFieldValues,
submitShortcutLabel,
hasMissingJiraCreateField
hasMissingJiraCreateField,
missingJiraCreateFieldNames,
searchJiraCreateUsers
}
const connectDialogs: TaskPageConnectDialogsProps = {
gitlabDialogItem,
@@ -6,8 +6,11 @@ import {
findJiraCreateAllowedValue,
getJiraCreateAllowedValueLabel,
getJiraCreateOptionPayload,
getJiraUserCreateFieldKeys,
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> = {}): JiraCreateField {
@@ -171,3 +174,83 @@ 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' }
})
})
})
// 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([])
})
})
@@ -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,18 @@ 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'
}
// 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<JiraCreateField['allowedValues']>[number]
): string {
@@ -40,6 +53,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 +88,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, string>
): string[] {
return fields
.filter((field) => !(values[field.key] ?? '').trim())
.map((field) => field.name || field.key)
}
export function buildJiraCreateCustomFields(
fields: readonly JiraCreateField[],
values: Record<string, string>
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from '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' }
const BLAIR = { accountId: '5c20b3955d31276811fef32h', displayName: 'Blair Chen' }
function renderPicker(
searchUsers: (query: string) => Promise<JiraUserSearchResult>,
value = '',
onValueChange = vi.fn()
) {
render(
<JiraUserFieldPicker
label="Reporter"
value={value}
onValueChange={onValueChange}
searchUsers={searchUsers}
/>
)
return { onValueChange }
}
const SEARCH_PLACEHOLDER = 'Search users or paste an account ID...'
async function open(): Promise<void> {
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(
<JiraUserFieldPicker
label="Reporter"
value={ALEX.accountId}
onValueChange={vi.fn()}
searchUsers={vi.fn(async () => ({ 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)
})
it('retains multiple picked users for an array user field', async () => {
const searchUsers = vi.fn(async () => ({ ok: true as const, users: [ALEX, BLAIR] }))
function Harness(): React.JSX.Element {
const [value, setValue] = useState('')
return (
<>
<JiraUserFieldPicker
label="Participants"
value={value}
onValueChange={setValue}
searchUsers={searchUsers}
multiple
/>
<output aria-label="picked users">{value}</output>
</>
)
}
render(<Harness />)
fireEvent.click(screen.getByRole('combobox', { name: 'Participants' }))
await waitFor(() => expect(screen.getByText(ALEX.displayName)).toBeTruthy())
fireEvent.click(screen.getByText(ALEX.displayName))
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeTruthy()
await waitFor(() => expect(screen.getByText(BLAIR.displayName)).toBeTruthy())
fireEvent.click(screen.getByText(BLAIR.displayName))
expect(screen.getByLabelText('picked users').textContent).toBe(
`${ALEX.accountId}, ${BLAIR.accountId}`
)
})
})
@@ -0,0 +1,246 @@
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,
multiple = false
}: {
label: string
value: string
onValueChange: (value: string) => void
// Must be referentially stable: it re-triggers the search when it changes.
searchUsers: (query: string) => Promise<JiraUserSearchResult>
disabled?: boolean
multiple?: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [search, setSearch] = useState<SearchState>({ status: 'ready', users: [] })
const [pickedNames, setPickedNames] = useState<Record<string, string>>({})
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 selectedAccountIds = useMemo(
() =>
multiple
? value
.split(',')
.map((accountId) => accountId.trim())
.filter(Boolean)
: value.trim()
? [value.trim()]
: [],
[multiple, value]
)
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 (selectedAccountIds.length === 0) {
return ''
}
return selectedAccountIds
.map(
(accountId) =>
users.find((user) => user.accountId === accountId)?.displayName ??
pickedNames[accountId] ??
accountId
)
.join(', ')
}, [pickedNames, selectedAccountIds, users])
const handleSelect = useCallback(
(accountId: string, displayName?: string) => {
const nextAccountIds = multiple
? selectedAccountIds.includes(accountId)
? selectedAccountIds.filter((selected) => selected !== accountId)
: [...selectedAccountIds, accountId]
: [accountId]
onValueChange(nextAccountIds.join(', '))
if (displayName) {
setPickedNames((prev) => ({ ...prev, [accountId]: displayName }))
}
if (!multiple) {
setOpen(false)
setQuery('')
}
},
[multiple, onValueChange, selectedAccountIds]
)
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) {
setQuery('')
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={label}
disabled={disabled}
className="h-9 w-full justify-between px-3 text-left text-xs font-normal"
>
{selectedLabel ? (
<span className="min-w-0 truncate">{selectedLabel}</span>
) : (
<span className="min-w-0 truncate text-muted-foreground">
{translate(
'auto.components.task.page.dialogs.jira.user.field.picker.selectUser',
'Select {{value0}}',
{ value0: label }
)}
</span>
)}
<ChevronDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={translate(
'auto.components.task.page.dialogs.jira.user.field.picker.searchPlaceholder',
'Search users or paste an account ID...'
)}
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-56">
{search.status === 'loading' ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate(
'auto.components.task.page.dialogs.jira.user.field.picker.searching',
'Searching users...'
)}
</div>
) : null}
{search.status === 'error' ? (
<div className="px-3 py-4 text-center text-xs text-destructive">
{translate(
'auto.components.task.page.dialogs.jira.user.field.picker.searchFailed',
"Couldn't search Jira users: {{value0}}",
{ value0: search.error }
)}
</div>
) : null}
{search.status === 'ready' ? (
<CommandEmpty>
{translate(
'auto.components.task.page.dialogs.jira.user.field.picker.noUsers',
'No matching users.'
)}
</CommandEmpty>
) : null}
{users.map((user) => (
<CommandItem
key={user.accountId}
value={user.accountId}
onSelect={() => handleSelect(user.accountId, user.displayName)}
className="items-center gap-2 px-3 py-2 text-xs"
>
<Check
className={cn(
'size-3.5 text-foreground',
selectedAccountIds.includes(user.accountId) ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="min-w-0 flex-1 truncate">{user.displayName}</span>
{user.email ? (
<span className="min-w-0 shrink truncate text-muted-foreground">
{user.email}
</span>
) : null}
</CommandItem>
))}
{showRawValueOption ? (
<CommandItem
value={`raw:${trimmedQuery}`}
onSelect={() => handleSelect(trimmedQuery)}
className="items-center gap-2 px-3 py-2 text-xs"
>
<Check
className={cn(
'size-3.5 text-foreground',
selectedAccountIds.includes(trimmedQuery) ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.task.page.dialogs.jira.user.field.picker.useTypedId',
'Use account ID "{{value0}}"',
{ value0: trimmedQuery }
)}
</span>
</CommandItem>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from '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' }
}
const PARTICIPANTS: JiraCreateField = {
key: 'customfield_10',
name: 'Participants',
required: true,
schema: { type: 'array', items: 'user' }
}
function renderFields(
fields: JiraCreateField[],
searchUsers = vi.fn(async () => ({ ok: true as const, users: [] }))
) {
const setValues = vi.fn()
render(
<NewJiraIssueCustomFields
visibleJiraCreateFields={fields}
newJiraIssueCustomFieldValues={{}}
setNewJiraIssueCustomFieldValues={setValues}
newJiraIssueSubmitting={false}
searchJiraCreateUsers={searchUsers}
/>
)
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('keeps every selection for a multi-user field', async () => {
const alex = { accountId: 'account-1', displayName: 'Alex Rivera' }
const blair = { accountId: 'account-2', displayName: 'Blair Chen' }
const searchUsers = vi.fn(async () => ({ ok: true as const, users: [alex, blair] }))
function Harness(): React.JSX.Element {
const [values, setValues] = useState<Record<string, string>>({})
return (
<>
<NewJiraIssueCustomFields
visibleJiraCreateFields={[PARTICIPANTS]}
newJiraIssueCustomFieldValues={values}
setNewJiraIssueCustomFieldValues={setValues}
newJiraIssueSubmitting={false}
searchJiraCreateUsers={searchUsers}
/>
<output aria-label="participant ids">{values[PARTICIPANTS.key]}</output>
</>
)
}
render(<Harness />)
fireEvent.click(screen.getByRole('combobox', { name: 'Participants' }))
await waitFor(() => expect(screen.getByText(alex.displayName)).toBeTruthy())
fireEvent.click(screen.getByText(alex.displayName))
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))
expect(screen.getByLabelText('participant ids').textContent).toBe('account-1, account-2')
})
it('renders nothing when there are no required fields', () => {
const { container } = render(
<NewJiraIssueCustomFields
visibleJiraCreateFields={[]}
newJiraIssueCustomFieldValues={{}}
setNewJiraIssueCustomFieldValues={vi.fn()}
newJiraIssueSubmitting={false}
searchJiraCreateUsers={vi.fn(async () => ({ ok: true as const, users: [] }))}
/>
)
expect(container.firstChild).toBeNull()
})
})
@@ -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<string, string>
setNewJiraIssueCustomFieldValues: React.Dispatch<React.SetStateAction<Record<string, string>>>
newJiraIssueSubmitting: boolean
searchJiraCreateUsers: (query: string) => Promise<JiraUserSearchResult>
}): React.JSX.Element | null {
if (visibleJiraCreateFields.length === 0) {
return null
@@ -33,7 +39,21 @@ export function NewJiraIssueCustomFields({
return (
<div key={field.key} className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">{field.name}</label>
{field.allowedValues?.length && field.schema?.type !== 'array' ? (
{isJiraUserCreateField(field) ? (
<JiraUserFieldPicker
label={field.name}
value={fieldValue}
onValueChange={(value) =>
setNewJiraIssueCustomFieldValues((prev) => ({
...prev,
[field.key]: value
}))
}
searchUsers={searchJiraCreateUsers}
disabled={newJiraIssueSubmitting}
multiple={field.schema?.type === 'array'}
/>
) : field.allowedValues?.length && field.schema?.type !== 'array' ? (
<Select
value={fieldValue}
onValueChange={(value) =>
@@ -33,7 +33,12 @@ import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { JiraCreateField, JiraIssueType, JiraProject } from '../../../../../shared/jira-types'
import type {
JiraCreateField,
JiraIssueType,
JiraProject,
JiraUserSearchResult
} from '../../../../../shared/jira-types'
export type NewJiraIssueDialogProps = {
newJiraIssueOpen: boolean
@@ -69,6 +74,8 @@ export type NewJiraIssueDialogProps = {
newJiraIssueCustomFieldValues: Record<string, string>
setNewJiraIssueCustomFieldValues: React.Dispatch<React.SetStateAction<Record<string, string>>>
hasMissingJiraCreateField: boolean
missingJiraCreateFieldNames: string[]
searchJiraCreateUsers: (query: string) => Promise<JiraUserSearchResult>
submitShortcutLabel: string
}
@@ -107,6 +114,8 @@ export function NewJiraIssueDialog(props: NewJiraIssueDialogProps): React.JSX.El
newJiraIssueCustomFieldValues,
setNewJiraIssueCustomFieldValues,
hasMissingJiraCreateField,
missingJiraCreateFieldNames,
searchJiraCreateUsers,
submitShortcutLabel
} = props
return (
@@ -308,10 +317,20 @@ export function NewJiraIssueDialog(props: NewJiraIssueDialogProps): React.JSX.El
) : null}
<NewJiraIssueCustomFields
visibleJiraCreateFields={visibleJiraCreateFields}
searchJiraCreateUsers={searchJiraCreateUsers}
newJiraIssueCustomFieldValues={newJiraIssueCustomFieldValues}
setNewJiraIssueCustomFieldValues={setNewJiraIssueCustomFieldValues}
newJiraIssueSubmitting={newJiraIssueSubmitting}
/>
{!jiraCreateFieldsLoading && missingJiraCreateFieldNames.length > 0 ? (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.task.page.dialogs.new.jira.issue.dialog.missingRequiredFields',
'Jira needs a value for {{value0}} before it will accept this issue.',
{ value0: missingJiraCreateFieldNames.join(', ') }
)}
</p>
) : null}
<p className="text-[10px] text-muted-foreground">
{submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')}
</p>
@@ -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
@@ -4,19 +4,27 @@ import { toast } from 'sonner'
import { filterJiraProjectPickerProjects } from '@/components/jira-project-picker-filter'
import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention'
import { translate } from '@/i18n/i18n'
import { jiraListCreateFields, jiraListIssueTypes } from '@/runtime/runtime-jira-client'
import {
jiraListCreateFields,
jiraListIssueTypes,
jiraSearchUsers
} from '@/runtime/runtime-jira-client'
import {
compareJiraProjectsByDisplayLabel,
getJiraProjectSelectionKey
} from '@/components/task-page-jira-project-selection'
import { isVisibleJiraCreateField } from '@/components/task-page-jira-create-fields'
import {
getMissingJiraCreateFieldNames,
isVisibleJiraCreateField
} from '@/components/task-page-jira-create-fields'
import { writeNewJiraIssueDraft } from '@/components/task-page/dialogs/task-creation-draft-writers'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import type {
JiraCreateField,
JiraIssueType,
JiraProject,
JiraSiteSelection
JiraSiteSelection,
JiraUserSearchResult
} from '../../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
@@ -102,14 +110,34 @@ export function useTaskPageJiraCreateDialog({
[jiraCreateFields]
)
const hasMissingJiraCreateField = useMemo(
() =>
visibleJiraCreateFields.some(
(field) => !(newJiraIssueCustomFieldValues[field.key] ?? '').trim()
),
const missingJiraCreateFieldNames = useMemo(
() => getMissingJiraCreateFieldNames(visibleJiraCreateFields, newJiraIssueCustomFieldValues),
[newJiraIssueCustomFieldValues, visibleJiraCreateFields]
)
const hasMissingJiraCreateField = missingJiraCreateFieldNames.length > 0
// Create has no issue key yet, so user search is scoped to the target project.
const searchJiraCreateUsers = useCallback(
async (query: string): Promise<JiraUserSearchResult> => {
if (!newJiraIssueTargetProject) {
return {
ok: false,
error: translate(
'auto.components.task.page.hooks.use.task.page.jira.create.dialog.jiraUserSearchNeedsProject',
'Choose a Jira project before picking a user.'
)
}
}
return jiraSearchUsers(jiraTaskSourceContext ?? settings, {
projectIdOrKey: newJiraIssueTargetProject.key || newJiraIssueTargetProject.id,
query,
siteId: newJiraIssueTargetProject.siteId ?? undefined
})
},
[jiraTaskSourceContext, newJiraIssueTargetProject, settings]
)
useEffect(() => {
if (!newJiraIssueProjectComboboxOpen) {
return
@@ -306,6 +334,8 @@ export function useTaskPageJiraCreateDialog({
newJiraIssueTargetType,
visibleJiraCreateFields,
hasMissingJiraCreateField,
missingJiraCreateFieldNames,
searchJiraCreateUsers,
handleNewJiraIssueProjectComboboxOpenChange,
handleNewJiraIssueProjectSelect,
handleNewJiraIssueProjectTriggerKeyDown
+34 -1
View File
@@ -16056,6 +16056,13 @@
"dialogDescription": "Create a Linear issue for the selected team."
}
}
},
"jira": {
"issue": {
"dialog": {
"missingRequiredFields": "Jira needs a value for {{value0}} before it will accept this issue."
}
}
}
},
"task": {
@@ -16066,6 +16073,20 @@
}
}
}
},
"jira": {
"user": {
"field": {
"picker": {
"selectUser": "Select {{value0}}",
"searchPlaceholder": "Search users or paste an account ID...",
"searching": "Searching users...",
"searchFailed": "Couldn't search Jira users: {{value0}}",
"noUsers": "No matching users.",
"useTypedId": "Use account ID \"{{value0}}\""
}
}
}
}
},
"github": {
@@ -16101,7 +16122,8 @@
"jira": {
"create": {
"dialog": {
"jiraRequiredFieldsLoadFailed": "Failed to load required Jira fields."
"jiraRequiredFieldsLoadFailed": "Failed to load required Jira fields.",
"jiraUserSearchNeedsProject": "Choose a Jira project before picking a user."
}
}
},
@@ -16492,6 +16514,17 @@
},
"gitlabIpcTimeout": {
"timedOut": "Timed out talking to GitLab."
},
"runtime": {
"jira": {
"user": {
"search": {
"unexpectedResponse": "Jira user search returned an unexpected response.",
"queryTooLong": "Search text is too long.",
"failed": "Failed to search Jira users."
}
}
}
}
},
"ssh": {
@@ -7,7 +7,8 @@ import {
jiraListAssignableUsers,
jiraLookupIssueSummary,
jiraReadStatus,
jiraSearchIssues
jiraSearchIssues,
jiraSearchUsers
} from './runtime-jira-client'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture'
@@ -17,6 +18,7 @@ type RuntimeSubscribeCallbacks = Parameters<typeof window.api.runtimeEnvironment
const jiraSearchIssuesLocal = vi.fn()
const jiraListAssignableUsersLocal = vi.fn()
const jiraSearchUsersLocal = vi.fn()
const jiraReadStatusLocal = vi.fn()
const jiraLookupIssueSummaryLocal = vi.fn()
const jiraCancelIssueSummaryLocal = vi.fn()
@@ -27,6 +29,7 @@ beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
jiraSearchIssuesLocal.mockReset()
jiraListAssignableUsersLocal.mockReset()
jiraSearchUsersLocal.mockReset()
jiraReadStatusLocal.mockReset()
jiraLookupIssueSummaryLocal.mockReset()
jiraCancelIssueSummaryLocal.mockReset()
@@ -39,7 +42,8 @@ beforeEach(() => {
lookupIssueSummary: jiraLookupIssueSummaryLocal,
cancelIssueSummary: jiraCancelIssueSummaryLocal,
searchIssues: jiraSearchIssuesLocal,
listAssignableUsers: jiraListAssignableUsersLocal
listAssignableUsers: jiraListAssignableUsersLocal,
searchUsers: jiraSearchUsersLocal
},
runtimeEnvironments: {
call: runtimeCall,
@@ -223,3 +227,128 @@ describe('runtime Jira client search bounds', () => {
expect(runtimeCall).not.toHaveBeenCalled()
})
})
describe('jiraSearchUsers', () => {
it('passes the project scope straight through on a local host', async () => {
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', displayName: 'Alex Doe' }]
})
expect(jiraSearchUsersLocal).toHaveBeenCalledWith({ projectIdOrKey: 'ENG', query: 'Alex' })
})
it('surfaces the host error instead of an empty user list', async () => {
jiraSearchUsersLocal.mockResolvedValue({ ok: false, error: 'Browse users is not permitted.' })
await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({
ok: false,
error: 'Browse users is not permitted.'
})
})
it('turns a thrown transport failure into a reported failure', async () => {
jiraSearchUsersLocal.mockRejectedValue(new Error('Unknown method: jira.searchUsers'))
await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({
ok: false,
error: 'Unknown method: jira.searchUsers'
})
})
// Runtime RPC results are cast, never decoded, so an older or drifted host can
// return any shape; it must not read as "this project has no users".
it('treats an unrecognized host payload as a failed search, not an empty one', async () => {
for (const payload of [undefined, null, [], { ok: true }, { ok: false }]) {
jiraSearchUsersLocal.mockResolvedValue(payload)
await expect(jiraSearchUsers(null, { projectIdOrKey: 'ENG' })).resolves.toEqual({
ok: false,
error: 'Jira user search returned an unexpected response.'
})
}
})
// 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(
{ activeRuntimeEnvironmentId: 'env-1' },
{
projectIdOrKey: 'ENG',
query: 'x'.repeat(9 * 1024)
}
)
).resolves.toEqual({ ok: false, error: 'Search text is too long.' })
expect(jiraSearchUsersLocal).not.toHaveBeenCalled()
expect(runtimeCall).not.toHaveBeenCalled()
})
})
@@ -25,6 +25,7 @@ import { readRuntimeJiraPayload } from './runtime-jira-payload-stream'
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
export { jiraLookupIssueSummary, jiraReadStatus } from './runtime-jira-summary-client'
export { jiraSearchUsers } from './runtime-jira-user-search'
export type { RuntimeJiraSettings } from './runtime-jira-target'
export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string }
@@ -0,0 +1,113 @@
import type { JiraUser, JiraUserSearchArgs, JiraUserSearchResult } from '../../../shared/jira-types'
import { translate } from '@/i18n/i18n'
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.
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)) {
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 unexpectedJiraUserSearchResponse()
}
export async function jiraSearchUsers(
settings: RuntimeJiraSettings,
args: JiraUserSearchArgs
): Promise<JiraUserSearchResult> {
if (!isRuntimeProviderSearchQueryWithinLimit(args.query)) {
return {
ok: false,
error: translate(
'auto.runtime.runtime.jira.user.search.queryTooLong',
'Search text is too long.'
)
}
}
const target = getJiraRuntimeTarget(settings)
try {
const result =
target.kind === 'environment'
? await callRuntimeRpc<unknown>(target, 'jira.searchUsers', args, { timeoutMs: 30_000 })
: await window.api.jira.searchUsers(args)
return normalizeJiraUserSearchResult(result)
} catch (error) {
return {
ok: false,
error:
error instanceof Error
? error.message
: translate(
'auto.runtime.runtime.jira.user.search.failed',
'Failed to search Jira users.'
)
}
}
}
+18
View File
@@ -73,6 +73,20 @@ export type JiraUser = {
avatarUrl?: string
}
// A user search is scoped to an existing issue (edit) or to a project (create,
// where no issue key exists yet). Both scopes are optional on the wire so a
// newer client can ask for either without breaking an older host's decoder.
export type JiraUserSearchArgs = {
projectIdOrKey?: string
issueKey?: string
query?: string
siteId?: string
}
// Result-shaped rather than a bare array: an empty dropdown must be
// distinguishable from a search that failed.
export type JiraUserSearchResult = { ok: true; users: JiraUser[] } | { ok: false; error: string }
export type JiraPriority = {
id: string
name: string
@@ -150,6 +164,10 @@ export type JiraCreateIssueArgs = {
title: string
description?: string
customFields?: Record<string, unknown>
// 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 =
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
buildJiraUserFieldValue,
isJiraUserFieldValue,
resolveJiraUserFieldValue,
resolveJiraUserFieldValues
} from './jira-user-field-value'
describe('buildJiraUserFieldValue', () => {
it('wraps a trimmed identifier and drops blank input', () => {
expect(buildJiraUserFieldValue(' 5abc ')).toEqual({ accountId: '5abc' })
expect(buildJiraUserFieldValue(' ')).toBeUndefined()
})
})
describe('isJiraUserFieldValue', () => {
const cases: [string, unknown, boolean][] = [
['marker', { accountId: '5abc' }, true],
['blank accountId', { accountId: ' ' }, false],
['option payload', { id: '5abc' }, false],
['bare string', '5abc', false],
['array', [{ accountId: '5abc' }], false],
['null', null, false]
]
for (const [label, value, expected] of cases) {
it(`returns ${expected} for a ${label}`, () => {
expect(isJiraUserFieldValue(value)).toBe(expected)
})
}
})
describe('resolveJiraUserFieldValue', () => {
it('sends Cloud an accountId under id', () => {
expect(resolveJiraUserFieldValue({ accountId: '5abc' }, undefined)).toEqual({ id: '5abc' })
expect(resolveJiraUserFieldValue({ accountId: '5abc' }, 'cloud')).toEqual({ id: '5abc' })
})
it('sends Server/DC a username under name, because it has no accountId', () => {
expect(resolveJiraUserFieldValue({ accountId: 'ada' }, 'server')).toEqual({ name: 'ada' })
})
})
describe('resolveJiraUserFieldValues', () => {
it('resolves each entry of a multi-user field', () => {
expect(
resolveJiraUserFieldValues([{ accountId: '5abc' }, { accountId: '5def' }], 'cloud')
).toEqual([{ id: '5abc' }, { id: '5def' }])
})
it('leaves non-user values untouched', () => {
expect(resolveJiraUserFieldValues({ id: 'opt-1' }, 'cloud')).toEqual({ id: 'opt-1' })
expect(resolveJiraUserFieldValues('plain text', 'server')).toBe('plain text')
expect(resolveJiraUserFieldValues(7, 'cloud')).toBe(7)
expect(resolveJiraUserFieldValues([{ value: 'a' }], 'server')).toEqual([{ value: 'a' }])
})
})
+60
View File
@@ -0,0 +1,60 @@
import type { JiraAuthType } from './jira-types'
// A user-typed Jira create field (reporter, assignee, custom user pickers) never
// travels as a bare string: Jira rejects that shape. The renderer emits this
// provider-neutral marker and the execution host resolves it against the site,
// because only the host knows whether the site is Cloud (accountId) or
// Server/DC (username).
export type JiraUserFieldValue = { accountId: string }
export function buildJiraUserFieldValue(accountId: string): JiraUserFieldValue | undefined {
const trimmed = accountId.trim()
return trimmed ? { accountId: trimmed } : undefined
}
export function isJiraUserFieldValue(value: unknown): value is JiraUserFieldValue {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const accountId = (value as { accountId?: unknown }).accountId
return typeof accountId === 'string' && accountId.trim().length > 0
}
// Cloud identifies users by accountId (`id`); Server/DC has no accountId and
// identifies them by username (`name`) — the same split updateIssue makes when
// assigning.
export function resolveJiraUserFieldValue(
value: JiraUserFieldValue,
authType: JiraAuthType | undefined
): { id: string } | { name: string } {
const accountId = value.accountId.trim()
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<string>,
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(
value: unknown,
authType: JiraAuthType | undefined
): unknown {
if (isJiraUserFieldValue(value)) {
return resolveJiraUserFieldValue(value, authType)
}
if (Array.isArray(value)) {
return value.map((entry) => resolveJiraUserFieldValues(entry, authType))
}
return value
}