feat(jira): pick the assignee when creating an issue — Automatic, me, or anyone (#24376)

* feat(jira): pick the assignee when creating an issue — Automatic, me, or anyone

The new-issue dialog gains an Assignee picker: Automatic (Jira applies the
project's default assignee), one-click "Assign to me" resolved from the
target site's stored identity, or any user via search. The selection resets
with the custom field values on project/type switches, since account ids
are site-scoped.

Creates now also send userFieldKeys, so the host shapes user-typed values
into the {accountId}/{name} refs Jira requires — previously the dialog
never passed them and user create fields were sent as bare strings. Keys
are only named when a value is present, keeping older remote hosts without
the user-fields capability working for assignee-less creates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jira): address assignee review — project-scoped assignable search, per-site picker remount, keep assignee across type changes

- Search the create dialog's assignee picker against /user/assignable/search?project=…
  (new listAssignableUsersForProject, IPC split into jira-user-search.ts for the
  max-lines budget); remote environment targets fall back to the existing
  site-wide search since older hosts have no such RPC
- Remount the picker per target project so cached results from the previous
  project or site cannot be selected after a switch
- Reset the picked assignee on project selection (account ids are site-scoped)
  instead of on every create-fields reload, so issue-type changes keep it
- Hide the assignee field and omit it from the payload when the target create
  screen does not accept an assignee — Jira rejects fields absent from the screen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jira): scope create assignee to the effective project and provider

* fix(jira): isolate assignee search candidates across provider changes

* fix(jira): search assignable users with Server-compatible project keys

* fix(jira): refresh assignee identity with provider connection status

* fix: distinguish hook confirmation from browser confirm

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
hnkƶ
2026-10-05 03:30:48 -07:00
committed by GitHub
co-authored by Claude Fable 5 Neil
parent 8b5de39217
commit fa180a9ee2
26 changed files with 788 additions and 45 deletions
+44
View File
@@ -0,0 +1,44 @@
import { ipcMain } from 'electron'
import { listAssignableUsers, listAssignableUsersForProject, searchUsers } from '../jira/issues'
function normalizeSiteId(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
/** Registers the `jira:*` user-search IPC handlers (assignable and site-wide). */
export function registerJiraUserSearchHandlers(): void {
ipcMain.handle(
'jira:listAssignableUsers',
async (_event, args: { key: string; query?: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return listAssignableUsers(
args.key.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle(
'jira:listAssignableUsersForProject',
async (_event, args: { projectIdOrKey: string; query?: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
return []
}
return listAssignableUsersForProject(
args.projectIdOrKey.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => {
return searchUsers(
typeof args?.query === 'string' ? args.query : undefined,
normalizeSiteId(args?.siteId)
)
})
}
+2 -22
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 { registerJiraUserSearchHandlers } from './jira-user-search'
import {
addIssueComment,
createIssue,
@@ -9,7 +10,6 @@ import {
getIssueSummary,
getIssueComments,
getProjectStatusOrder,
listAssignableUsers,
listCreateFields,
listIssueTypes,
listIssues,
@@ -17,7 +17,6 @@ import {
listProjects,
listTransitions,
searchIssues,
searchUsers,
updateIssue
} from '../jira/issues'
import type {
@@ -285,26 +284,7 @@ export function registerJiraHandlers(): void {
return listPriorities(normalizeSiteId(args?.siteId))
})
ipcMain.handle(
'jira:listAssignableUsers',
async (_event, args: { key: string; query?: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return listAssignableUsers(
args.key.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => {
return searchUsers(
typeof args?.query === 'string' ? args.query : undefined,
normalizeSiteId(args?.siteId)
)
})
registerJiraUserSearchHandlers()
ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
+23
View File
@@ -119,6 +119,29 @@ describe('Jira issue operations', () => {
)
})
it('scopes create-time assignable search by project, with the Server username param', async () => {
jiraRequestMock.mockResolvedValueOnce([{ accountId: 'acc-1', displayName: 'Ada' }])
const { listAssignableUsersForProject } = await import('./issues')
const cloudUsers = await listAssignableUsersForProject('ALP', 'ada', 'site-1')
expect(jiraRequestMock).toHaveBeenCalledWith(
expect.anything(),
'/rest/api/3/user/assignable/search?project=ALP&maxResults=50&query=ada'
)
expect(cloudUsers).toEqual([expect.objectContaining({ accountId: 'acc-1' })])
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValueOnce([])
await listAssignableUsersForProject('ALP', 'ada', 'server-1')
expect(jiraRequestMock).toHaveBeenLastCalledWith(
expect.anything(),
'/rest/api/2/user/assignable/search?project=ALP&maxResults=50&username=ada'
)
})
it('loads Jira summaries without descriptions, rendered fields, or attachment media', async () => {
jiraRequestMock.mockResolvedValueOnce({
id: 'issue-1',
+1
View File
@@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments'
export { listProjects } from './jira-project-queries'
export {
listAssignableUsers,
listAssignableUsersForProject,
listCreateFields,
listIssueTypes,
listPriorities,
+29 -6
View File
@@ -162,17 +162,18 @@ export async function searchUsers(query?: string, siteId?: string | null): Promi
}
}
export async function listAssignableUsers(
key: string,
query?: string,
siteId?: string | null
async function searchAssignableUsers(
scope: { issueKey: string } | { project: string },
query: string | undefined,
siteId: string | null | undefined,
label: string
): Promise<JiraUser[]> {
const entry = getClients(siteId)[0]
if (!entry) {
return []
}
const isServer = entry.site.authType === 'server'
const params = new URLSearchParams({ issueKey: key, maxResults: '50' })
const params = new URLSearchParams({ ...scope, maxResults: '50' })
if (query?.trim()) {
// Server/DC filters assignable users by `username`; `query` is Cloud-only.
params.set(isServer ? 'username' : 'query', query.trim())
@@ -189,9 +190,31 @@ export async function listAssignableUsers(
clearToken(entry.site.id)
throw error
}
console.warn('[jira] listAssignableUsers failed:', error)
console.warn(`[jira] ${label} failed:`, error)
return []
} finally {
release()
}
}
export async function listAssignableUsers(
key: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
return searchAssignableUsers({ issueKey: key }, query, siteId, 'listAssignableUsers')
}
// Creation has no issue key yet, so assignability is scoped by project.
export async function listAssignableUsersForProject(
projectIdOrKey: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
return searchAssignableUsers(
{ project: projectIdOrKey },
query,
siteId,
'listAssignableUsersForProject'
)
}
+5
View File
@@ -76,6 +76,11 @@ export type JiraApi = {
query?: string
siteId?: string
}) => Promise<JiraUser[]>
listAssignableUsersForProject: (args: {
projectIdOrKey: string
query?: string
siteId?: string
}) => Promise<JiraUser[]>
searchUsers: (args?: { query?: string; siteId?: string }) => Promise<JiraUser[]>
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
getProjectStatusOrder: (args: {
+6
View File
@@ -46,6 +46,7 @@ export const jiraApi = {
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),
@@ -78,6 +79,11 @@ export const jiraApi = {
listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) =>
ipcRenderer.invoke('jira:listAssignableUsers', args),
listAssignableUsersForProject: (args: {
projectIdOrKey: string
query?: string
siteId?: string
}) => ipcRenderer.invoke('jira:listAssignableUsersForProject', args),
searchUsers: (args?: { query?: string; siteId?: string }) =>
ipcRenderer.invoke('jira:searchUsers', args),
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import type { JiraConnectionStatus } from '../../../shared/jira-types'
import { getJiraSelfUser } from './jira-self-user'
const viewer = {
accountId: 'viewer-1',
displayName: 'Viewer One',
email: 'viewer@example.com'
}
function status(partial: Partial<JiraConnectionStatus>): JiraConnectionStatus {
return { connected: true, viewer, ...partial }
}
describe('getJiraSelfUser', () => {
it('prefers the stored identity of the target site', () => {
const result = getJiraSelfUser(
status({
activeSiteId: 'a',
sites: [
{
id: 'a',
siteUrl: 'https://a',
email: 'a@example.com',
displayName: 'User A',
accountId: 'acc-a'
},
{
id: 'b',
siteUrl: 'https://b',
email: 'b@example.com',
displayName: 'User B',
accountId: 'acc-b'
}
]
}),
'b'
)
expect(result).toEqual({ accountId: 'acc-b', displayName: 'User B' })
})
it('does not fall back to the viewer for a different, unknown site', () => {
const result = getJiraSelfUser(
status({
activeSiteId: 'a',
sites: [
{
id: 'a',
siteUrl: 'https://a',
email: 'a@example.com',
displayName: 'User A',
accountId: 'acc-a'
},
{ id: 'b', siteUrl: 'https://b', email: 'b@example.com', displayName: '', accountId: '' }
]
}),
'b'
)
expect(result).toBeNull()
})
it('falls back to the viewer for the active site and for siteless single-site setups', () => {
const withActive = getJiraSelfUser(
status({
activeSiteId: 'a',
sites: [
{ id: 'a', siteUrl: 'https://a', email: 'a@example.com', displayName: '', accountId: '' },
{
id: 'b',
siteUrl: 'https://b',
email: 'b@example.com',
displayName: 'User B',
accountId: 'acc-b'
}
]
}),
'a'
)
expect(withActive?.accountId).toBe('viewer-1')
const withoutSiteId = getJiraSelfUser(status({}), null)
expect(withoutSiteId?.accountId).toBe('viewer-1')
})
it('never borrows the viewer for an explicitly unknown site', () => {
expect(getJiraSelfUser(status({ activeSiteId: 'a' }), 'b')).toBeNull()
expect(
getJiraSelfUser(
status({
activeSiteId: 'a',
sites: [{ id: 'a', siteUrl: 'https://a', email: '', displayName: '', accountId: '' }]
}),
'b'
)
).toBeNull()
})
it('returns null when nothing is connected', () => {
expect(getJiraSelfUser(null, 'a')).toBeNull()
expect(getJiraSelfUser(status({ connected: false }), null)).toBeNull()
expect(getJiraSelfUser({ connected: false, viewer: null }, null)).toBeNull()
})
})
@@ -0,0 +1,32 @@
import type { JiraConnectionStatus, JiraUser } from '../../../shared/jira-types'
// Site identities take precedence over the active site's viewer.
export function getJiraSelfUser(
status: JiraConnectionStatus | null | undefined,
siteId: string | null | undefined
): JiraUser | null {
if (!status?.connected) {
return null
}
const sites = status?.sites ?? []
const site = siteId ? sites.find((candidate) => candidate.id === siteId) : null
if (site?.accountId) {
return {
accountId: site.accountId,
displayName: site.displayName || site.email || site.accountId
}
}
const viewer = status?.viewer
if (
viewer?.accountId &&
(!siteId || siteId === status.activeSiteId || (site?.id === siteId && sites.length === 1))
) {
return {
accountId: viewer.accountId,
displayName: viewer.displayName,
email: viewer.email,
avatarUrl: viewer.avatarUrl
}
}
return null
}
@@ -43,6 +43,12 @@ export function JiraUserOptionList({
* not the display name a plain text box would collect, since Jira rejects a bare
* string for user fields.
*/
export type JiraUserPickerFixedOption = {
key: string
label: string
onSelect: () => void
}
export function JiraUserPicker({
providerSettings,
siteId,
@@ -50,7 +56,9 @@ export function JiraUserPicker({
selectedUser,
onSelect,
disabled,
label
label,
fixedOptions,
searchUsers
}: {
providerSettings: TaskSourceContext | GlobalSettings | null
siteId?: string | null
@@ -59,6 +67,10 @@ export function JiraUserPicker({
onSelect: (user: JiraUser) => void
disabled?: boolean
label: string
/** Rows pinned above the search results, e.g. "Automatic" or "Assign to me". */
fixedOptions?: JiraUserPickerFixedOption[]
/** Replaces the site-wide user search, e.g. with a project-scoped assignable search. Must be referentially stable. */
searchUsers?: (query: string) => Promise<JiraUser[]>
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
@@ -72,7 +84,7 @@ export function JiraUserPicker({
let cancelled = false
setLoading(true)
const timer = setTimeout(() => {
void jiraSearchUsers(providerSettings, query, siteId)
void (searchUsers ? searchUsers(query) : jiraSearchUsers(providerSettings, query, siteId))
.then((found) => {
if (!cancelled) {
setUsers(found)
@@ -93,7 +105,7 @@ export function JiraUserPicker({
cancelled = true
clearTimeout(timer)
}
}, [open, providerSettings, query, siteId])
}, [open, providerSettings, query, searchUsers, siteId])
const triggerLabel = useMemo(() => {
if (selectedUser?.displayName) {
@@ -130,6 +142,19 @@ export function JiraUserPicker({
className="mb-1 h-7 text-[12px]"
autoFocus
/>
{fixedOptions?.map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
option.onSelect()
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{option.label}
</button>
))}
{users.length === 0 && !loading ? (
<p className="px-2 py-1.5 text-[12px] text-muted-foreground">
{translate('components.jiraUserPicker.empty', 'No users found')}
@@ -3,10 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
buildJiraCreateCustomFields,
buildJiraCreateFieldValue,
buildJiraCreateSubmission,
findJiraCreateAllowedValue,
getJiraCreateAllowedValueLabel,
getJiraCreateOptionPayload,
getJiraUserCreateFieldKeys,
hasJiraAssigneeCreateField,
isJiraScalarUserCreateField,
isJiraUserCreateField,
isVisibleJiraCreateField
@@ -25,6 +27,8 @@ describe('isVisibleJiraCreateField', () => {
{ key: 'issuetype', required: true, expected: false },
{ key: 'summary', required: true, expected: false },
{ key: 'description', required: true, expected: false },
// the dialog renders a dedicated assignee picker, so the generic field must not show
{ key: 'assignee', required: true, expected: false },
// characterization: current behavior — the system-field filter is exact-match
// and case-sensitive, so a differently-cased key stays visible.
{ key: 'Summary', required: true, expected: true }
@@ -222,3 +226,55 @@ describe('buildJiraCreateCustomFields', () => {
expect(buildJiraCreateCustomFields([field({ key: 'a' })], { other: 'x' })).toBeUndefined()
})
})
describe('hasJiraAssigneeCreateField', () => {
it('detects whether the create screen accepts an assignee', () => {
expect(hasJiraAssigneeCreateField([field({ key: 'assignee', required: false })])).toBe(true)
expect(hasJiraAssigneeCreateField([field({ key: 'reporter' })])).toBe(false)
expect(hasJiraAssigneeCreateField([])).toBe(false)
})
})
describe('buildJiraCreateSubmission', () => {
const userField = field({ key: 'customfield_9', schema: { type: 'user' } })
it('omits assignee and userFieldKeys for the Automatic default', () => {
expect(buildJiraCreateSubmission([field({ key: 'a' })], { a: 'one' }, null)).toEqual({
customFields: { a: 'one' },
userFieldKeys: undefined
})
expect(buildJiraCreateSubmission([], {}, undefined)).toEqual({
customFields: undefined,
userFieldKeys: undefined
})
})
it('adds the picked assignee to customFields and names it as a user field', () => {
expect(buildJiraCreateSubmission([], {}, 'acc-1')).toEqual({
customFields: { assignee: 'acc-1' },
userFieldKeys: ['assignee']
})
})
it('names only the user-typed fields that carry a value', () => {
expect(
buildJiraCreateSubmission(
[userField, field({ key: 'customfield_8', schema: { type: 'user' } })],
{ customfield_9: 'acc-9' },
'acc-1'
)
).toEqual({
customFields: { customfield_9: 'acc-9', assignee: 'acc-1' },
userFieldKeys: ['customfield_9', 'assignee']
})
})
it('keeps userFieldKeys absent when user fields exist but are blank', () => {
// Why it matters: a non-empty key list trips the remote user-fields
// capability gate, which older hosts fail even with nothing to shape.
expect(buildJiraCreateSubmission([userField], {}, null)).toEqual({
customFields: undefined,
userFieldKeys: undefined
})
})
})
@@ -1,11 +1,25 @@
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
import type { JiraCreateField } from '../../../shared/jira-types'
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
// 'assignee' is system here because the dialog renders a dedicated picker for it.
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set([
'project',
'issuetype',
'summary',
'description',
'assignee'
])
/** Jira's own create screen defaults only this field to the authenticated user. */
export const JIRA_REPORTER_FIELD_KEY = 'reporter'
export const JIRA_ASSIGNEE_FIELD_KEY = 'assignee'
// Jira rejects fields absent from the create screen.
export function hasJiraAssigneeCreateField(fields: readonly JiraCreateField[]): boolean {
return fields.some((field) => field.key === JIRA_ASSIGNEE_FIELD_KEY)
}
/** True for required create fields the dialog must render (system fields excluded). */
export function isVisibleJiraCreateField(field: JiraCreateField): boolean {
return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key)
@@ -110,3 +124,25 @@ export function buildJiraCreateCustomFields(
}
return Object.keys(customFields).length > 0 ? customFields : undefined
}
// Only populated user fields require the remote user-field capability.
export function buildJiraCreateSubmission(
fields: readonly JiraCreateField[],
values: Record<string, string>,
assigneeAccountId?: string | null
): { customFields?: Record<string, unknown>; userFieldKeys?: string[] } {
const customFields = { ...buildJiraCreateCustomFields(fields, values) }
if (assigneeAccountId) {
customFields[JIRA_ASSIGNEE_FIELD_KEY] = assigneeAccountId
}
const userFieldKeys = getJiraUserCreateFieldKeys(fields).filter(
(key) => customFields[key] !== undefined
)
if (assigneeAccountId) {
userFieldKeys.push(JIRA_ASSIGNEE_FIELD_KEY)
}
return {
customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
userFieldKeys: userFieldKeys.length > 0 ? userFieldKeys : undefined
}
}
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import type { ComponentProps } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField'
const search = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-jira-client', () => ({ jiraListAssignableUsersForProject: search }))
afterEach(cleanup)
it('drops old provider candidates before another host with identical project IDs can select them', async () => {
const onSelect = vi.fn()
const model: ComponentProps<typeof TaskPageJiraIssueAssigneeField>['model'] = {
settings: null,
jiraTaskSourceContext: null,
jiraStatusCurrent: true,
jiraStatus: {
connected: true,
activeSiteId: 'same-site',
viewer: { accountId: 'local-me', displayName: 'Local me', email: null }
},
jiraCreateFields: [{ key: 'assignee', name: 'Assignee', required: false }],
newJiraIssueTargetProject: { id: '100', key: 'PRJ', name: 'Project', siteId: 'same-site' },
newJiraIssueAssignee: null,
setNewJiraIssueAssignee: onSelect,
newJiraIssueSubmitting: false,
providerRuntimeContextKey: 'local'
}
search.mockResolvedValueOnce([{ accountId: 'local-user', displayName: 'Local user' }])
const { rerender } = render(<TaskPageJiraIssueAssigneeField model={model} />)
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
await screen.findByRole('button', { name: 'Local user' })
expect(screen.getByRole('button', { name: 'Assign to me (Local me)' })).toBeTruthy()
expect(search).toHaveBeenCalledWith(null, 'PRJ', '', 'same-site')
search.mockReturnValueOnce(new Promise(() => {}))
rerender(
<TaskPageJiraIssueAssigneeField
model={{ ...model, providerRuntimeContextKey: 'runtime:remote', jiraStatusCurrent: false }}
/>
)
expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
await waitFor(() => expect(search).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Assign to me (Local me)' })).toBeNull()
rerender(
<TaskPageJiraIssueAssigneeField
model={{
...model,
providerRuntimeContextKey: 'runtime:remote',
jiraStatus: {
...model.jiraStatus,
viewer: { accountId: 'remote-me', displayName: 'Remote me', email: null }
}
}}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Assign to me (Remote me)' }))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'remote-me' }))
})
@@ -0,0 +1,100 @@
import { useCallback } from 'react'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { getJiraSelfUser } from '@/components/jira-self-user'
import { JiraUserPicker, type JiraUserPickerFixedOption } from '@/components/jira-user-picker'
import { hasJiraAssigneeCreateField } from '@/components/task-page-jira-create-fields'
import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection'
import { jiraListAssignableUsersForProject } from '@/runtime/runtime-jira-client'
import { translate } from '@/i18n/i18n'
export function TaskPageJiraIssueAssigneeField({
model
}: {
model: Pick<
TaskPageComposerActionsModel,
| 'settings'
| 'jiraTaskSourceContext'
| 'jiraStatus'
| 'jiraStatusCurrent'
| 'jiraCreateFields'
| 'newJiraIssueTargetProject'
| 'newJiraIssueAssignee'
| 'setNewJiraIssueAssignee'
| 'newJiraIssueSubmitting'
| 'providerRuntimeContextKey'
>
}): React.JSX.Element | null {
const {
settings,
jiraTaskSourceContext,
jiraStatus,
jiraCreateFields,
newJiraIssueTargetProject,
newJiraIssueAssignee,
setNewJiraIssueAssignee,
newJiraIssueSubmitting
} = model
const providerSettings = jiraTaskSourceContext ?? settings
const projectKey = newJiraIssueTargetProject?.key
const projectSiteId = newJiraIssueTargetProject?.siteId
const searchAssignableUsers = useCallback(
(query: string) =>
projectKey
? jiraListAssignableUsersForProject(providerSettings, projectKey, query, projectSiteId)
: Promise.resolve([]),
[projectKey, projectSiteId, providerSettings]
)
if (!hasJiraAssigneeCreateField(jiraCreateFields)) {
return null
}
const selfUser = model.jiraStatusCurrent
? getJiraSelfUser(jiraStatus, projectSiteId ?? null)
: null
const assigneeLabel = translate('components.jiraIssueAssigneeField.label', 'Assignee')
const automaticLabel = translate('components.jiraIssueAssigneeField.automatic', 'Automatic')
const fixedOptions: JiraUserPickerFixedOption[] = [
{
key: 'automatic',
label: automaticLabel,
onSelect: () => setNewJiraIssueAssignee(null)
},
...(selfUser
? [
{
key: 'self',
label: translate(
'components.jiraIssueAssigneeField.assignToMe',
'Assign to me ({{name}})',
{ name: selfUser.displayName }
),
onSelect: () => setNewJiraIssueAssignee(selfUser)
}
]
: [])
]
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">{assigneeLabel}</label>
<JiraUserPicker
// Cached candidates belong to one provider, project, and site.
key={JSON.stringify([
model.providerRuntimeContextKey,
newJiraIssueTargetProject
? getJiraProjectSelectionKey(newJiraIssueTargetProject)
: 'no-project'
])}
providerSettings={providerSettings}
siteId={projectSiteId ?? undefined}
value={newJiraIssueAssignee ? newJiraIssueAssignee.accountId : automaticLabel}
selectedUser={newJiraIssueAssignee}
onSelect={(user) => setNewJiraIssueAssignee(user)}
disabled={newJiraIssueSubmitting}
label={assigneeLabel}
fixedOptions={fixedOptions}
searchUsers={searchAssignableUsers}
/>
</div>
</div>
)
}
@@ -31,6 +31,7 @@ import {
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { getJiraCreateAllowedValueLabel } from '@/components/task-page-jira-create-fields'
import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField'
export function TaskPageJiraIssueDialog({
model
}: {
@@ -260,6 +261,7 @@ export function TaskPageJiraIssueDialog({
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek"
/>
</div>
<TaskPageJiraIssueAssigneeField model={model} />
{jiraCreateFieldsLoading ? (
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useJiraCreateAssignee } from './use-jira-create-assignee'
const user = { accountId: 'account-a', displayName: 'Ada' }
describe('useJiraCreateAssignee', () => {
it('clears the selection when a site filter implicitly changes the target project', () => {
const { result, rerender } = renderHook(
({ project }) => useJiraCreateAssignee('local', project),
{ initialProps: { project: 'site-a::100' } }
)
act(() => result.current.setNewJiraIssueAssignee(user))
const staleSelect = result.current.setNewJiraIssueAssignee
rerender({ project: 'site-b::100' })
expect(result.current.newJiraIssueAssignee).toBeNull()
act(() => staleSelect(user))
expect(result.current.newJiraIssueAssignee).toBeNull()
rerender({ project: 'site-a::100' })
expect(result.current.newJiraIssueAssignee).toBeNull()
})
it('clears across providers even when the dialog was closed', () => {
const { result, rerender } = renderHook(
({ provider }) => useJiraCreateAssignee(provider, 'site-a::100'),
{ initialProps: { provider: 'local' } }
)
act(() => result.current.setNewJiraIssueAssignee(user))
rerender({ provider: 'runtime:remote' })
expect(result.current.newJiraIssueAssignee).toBeNull()
})
it('retains the assignee across unrelated renders and supports Automatic', () => {
const { result, rerender } = renderHook(() => useJiraCreateAssignee('local', 'site-a::100'))
act(() => result.current.setNewJiraIssueAssignee(user))
rerender()
expect(result.current.newJiraIssueAssignee).toEqual(user)
act(() => result.current.setNewJiraIssueAssignee(null))
expect(result.current.newJiraIssueAssignee).toBeNull()
})
})
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react'
import type { JiraUser } from '../../../shared/jira-types'
export function useJiraCreateAssignee(providerKey: string, projectKey: string) {
const scope = JSON.stringify([providerKey, projectKey])
const [selection, setSelection] = useState<{ scope: string; user: JiraUser | null }>({
scope,
user: null
})
// Reset during render so an implicit project change cannot submit the previous site's user.
if (selection.scope !== scope) {
setSelection({ scope, user: null })
}
const setNewJiraIssueAssignee = useCallback(
(user: JiraUser | null) =>
setSelection((current) => (current.scope === scope ? { scope, user } : current)),
[scope]
)
return {
newJiraIssueAssignee: selection.scope === scope ? selection.user : null,
setNewJiraIssueAssignee
}
}
@@ -1,3 +1,4 @@
import { useJiraCreateAssignee } from './use-jira-create-assignee'
import React, { useCallback, useEffect, useMemo } from 'react'
import { filterJiraProjectPickerProjects } from '@/components/jira-project-picker-filter'
@@ -54,6 +55,10 @@ export function useTaskPageJiraCreationProjects(model: TaskPageJiraCreationState
const newJiraIssueTargetProjectSelectionKey = newJiraIssueTargetProject
? getJiraProjectSelectionKey(newJiraIssueTargetProject)
: ''
const { newJiraIssueAssignee, setNewJiraIssueAssignee } = useJiraCreateAssignee(
model.providerRuntimeContextKey,
newJiraIssueTargetProjectSelectionKey
)
const newJiraIssueTargetType = useMemo(
() =>
availableJiraIssueTypes.find((issueType) => issueType.id === newJiraIssueTypeId) ??
@@ -173,7 +178,7 @@ export function useTaskPageJiraCreationProjects(model: TaskPageJiraCreationState
handleNewJiraIssueProjectComboboxOpenChange
nextModel.handleNewJiraIssueProjectSelect = handleNewJiraIssueProjectSelect
nextModel.handleNewJiraIssueProjectTriggerKeyDown = handleNewJiraIssueProjectTriggerKeyDown
return nextModel
return Object.assign(nextModel, { newJiraIssueAssignee, setNewJiraIssueAssignee })
}
export type TaskPageJiraCreationProjectsModel = ReturnType<typeof useTaskPageJiraCreationProjects>
@@ -1,6 +1,9 @@
import type { TaskPageLinearIssueCreationModel } from './use-task-page-linear-issue-creation'
import { useCallback } from 'react'
import { buildJiraCreateCustomFields } from '@/components/task-page-jira-create-fields'
import {
buildJiraCreateSubmission,
hasJiraAssigneeCreateField
} from '@/components/task-page-jira-create-fields'
import { jiraCreateIssue, jiraGetIssue } from '@/runtime/runtime-jira-client'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
@@ -20,9 +23,12 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
setNewJiraIssueBody,
newJiraIssueSubmitting,
setNewJiraIssueSubmitting,
jiraCreateFields,
jiraCreateFieldsLoading,
newJiraIssueCustomFieldValues,
setNewJiraIssueCustomFieldValues,
newJiraIssueAssignee,
setNewJiraIssueAssignee,
discardNewJiraIssueDraft,
newJiraIssueTargetProject,
newJiraIssueTargetType,
@@ -37,9 +43,12 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
if (!title || newJiraIssueSubmitting || hasMissingJiraCreateField || jiraCreateFieldsLoading) {
return
}
const customFields = buildJiraCreateCustomFields(
const { customFields, userFieldKeys } = buildJiraCreateSubmission(
visibleJiraCreateFields,
newJiraIssueCustomFieldValues
newJiraIssueCustomFieldValues,
// Why: Jira rejects creates that set a field absent from the create
// screen, so a picked assignee only ships when the screen accepts one.
hasJiraAssigneeCreateField(jiraCreateFields) ? newJiraIssueAssignee?.accountId : undefined
)
setNewJiraIssueSubmitting(true)
const submitProviderRuntimeContextKey = providerRuntimeContextKey
@@ -50,7 +59,8 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
issueTypeId: newJiraIssueTargetType.id,
title,
description: newJiraIssueBody || undefined,
customFields
customFields,
userFieldKeys
})
if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) {
return
@@ -80,6 +90,7 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
setNewJiraIssueTitle('')
setNewJiraIssueBody('')
setNewJiraIssueCustomFieldValues({})
setNewJiraIssueAssignee(null)
setJiraRefreshNonce((n) => n + 1)
void jiraGetIssue(
jiraTaskSourceContext ?? settings,
@@ -112,7 +123,9 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
}
}, [
hasMissingJiraCreateField,
jiraCreateFields,
jiraCreateFieldsLoading,
newJiraIssueAssignee,
newJiraIssueBody,
newJiraIssueCustomFieldValues,
newJiraIssueSubmitting,
@@ -132,6 +145,7 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM
setNewJiraIssueTitle,
setNewJiraIssueBody,
setNewJiraIssueCustomFieldValues,
setNewJiraIssueAssignee,
setNewJiraIssueSubmitting
])
const nextModel = model as typeof model & {
+5
View File
@@ -17694,6 +17694,11 @@
}
}
},
"jiraIssueAssigneeField": {
"label": "Assignee",
"automatic": "Automatic",
"assignToMe": "Assign to me ({{name}})"
},
"jiraUserPicker": {
"select": "Select {{value0}}",
"search": "Search users",
@@ -14,7 +14,7 @@ export async function prepareWorktreeCreationHooks(
}
const isCancelled = (): boolean => !useAppStore.getState().pendingWorktreeCreations[creationId]
const confirm = (kind: 'setup' | 'vmRecipe'): Promise<'run' | 'skip'> =>
const confirmHook = (kind: 'setup' | 'vmRecipe'): Promise<'run' | 'skip'> =>
ensureHooksConfirmed(
useAppStore.getState,
request.repoId,
@@ -23,7 +23,7 @@ export async function prepareWorktreeCreationHooks(
undefined,
isCancelled
)
const trustDecision = await confirm('setup')
const trustDecision = await confirmHook('setup')
if (isCancelled()) {
return null
}
@@ -48,7 +48,7 @@ export async function prepareWorktreeCreationHooks(
}
if (preparation.confirmVmRecipe) {
const decision = await confirm('vmRecipe')
const decision = await confirmHook('vmRecipe')
if (isCancelled()) {
return null
}
@@ -6,6 +6,7 @@ import {
jiraGetIssue,
jiraIssueComments,
jiraListAssignableUsers,
jiraListAssignableUsersForProject,
jiraLookupIssueSummary,
jiraReadStatus,
jiraSearchIssues,
@@ -24,6 +25,7 @@ type RuntimeSubscribeCallbacks = Parameters<typeof window.api.runtimeEnvironment
const jiraSearchIssuesLocal = vi.fn()
const jiraListAssignableUsersLocal = vi.fn()
const jiraSearchUsersLocal = vi.fn()
const jiraProjectUsersLocal = vi.fn()
const jiraCreateIssueLocal = vi.fn()
const jiraReadStatusLocal = vi.fn()
const jiraLookupIssueSummaryLocal = vi.fn()
@@ -36,6 +38,7 @@ beforeEach(() => {
jiraSearchIssuesLocal.mockReset()
jiraListAssignableUsersLocal.mockReset()
jiraSearchUsersLocal.mockReset()
jiraProjectUsersLocal.mockReset()
jiraCreateIssueLocal.mockReset()
jiraReadStatusLocal.mockReset()
jiraLookupIssueSummaryLocal.mockReset()
@@ -51,6 +54,7 @@ beforeEach(() => {
searchIssues: jiraSearchIssuesLocal,
listAssignableUsers: jiraListAssignableUsersLocal,
searchUsers: jiraSearchUsersLocal,
listAssignableUsersForProject: jiraProjectUsersLocal,
createIssue: jiraCreateIssueLocal
},
runtimeEnvironments: {
@@ -332,6 +336,54 @@ describe('runtime Jira client search bounds', () => {
)
})
it('uses project-scoped search locally and rejects oversized queries before IPC', async () => {
jiraProjectUsersLocal.mockResolvedValue([])
await jiraListAssignableUsersForProject(null, '100', 'Ada', 'site-1')
expect(jiraProjectUsersLocal).toHaveBeenCalledWith({
projectIdOrKey: '100',
query: 'Ada',
siteId: 'site-1'
})
await jiraListAssignableUsersForProject(null, '100', 'x'.repeat(20_000), 'site-1')
expect(jiraProjectUsersLocal).toHaveBeenCalledTimes(1)
})
it('falls back to site-wide remote search only when the host supports user fields', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'status.get'
? createCompatibleRuntimeStatusResponse()
: { id: 'rpc-1', ok: true, result: [], _meta: { runtimeId: 'remote-runtime' } }
)
await jiraListAssignableUsersForProject(
{ activeRuntimeEnvironmentId: 'env-1' },
'100',
'Ada',
'site-1'
)
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'jira.searchUsers',
selector: 'env-1',
params: { query: 'Ada', siteId: 'site-1' }
})
)
expect(jiraProjectUsersLocal).not.toHaveBeenCalled()
clearRuntimeCompatibilityCacheForTests()
runtimeCall.mockReset().mockResolvedValue(createRuntimeStatusWithoutJiraUserFieldsCapability())
await expect(
jiraListAssignableUsersForProject(
{ activeRuntimeEnvironmentId: 'env-1' },
'100',
'Ada',
'site-1'
)
).resolves.toEqual([])
expect(runtimeCall).toHaveBeenCalledTimes(1)
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'jira.searchUsers' })
)
})
it('streams image-bearing issue and comment payloads from remote runtimes', async () => {
runtimeSubscribe.mockImplementation(
async (args: RuntimeSubscribeArgs, callbacks: RuntimeSubscribeCallbacks) => {
@@ -25,6 +25,7 @@ export { jiraLookupIssueSummary, jiraReadStatus } from './runtime-jira-summary-c
export {
jiraCreateIssue,
jiraListAssignableUsers,
jiraListAssignableUsersForProject,
jiraSearchUsers
} from './runtime-jira-user-fields-client'
export type { RuntimeJiraSettings } from './runtime-jira-target'
@@ -49,6 +49,27 @@ export async function jiraListAssignableUsers(
: window.api.jira.listAssignableUsers(args)
}
// Remote hosts lack project search; use their capability-gated site search.
export async function jiraListAssignableUsersForProject(
settings: RuntimeJiraSettings,
projectIdOrKey: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
return []
}
const target = getJiraRuntimeTarget(settings)
if (target.kind === 'environment') {
return jiraSearchUsers(settings, query, siteId)
}
return window.api.jira.listAssignableUsersForProject({
projectIdOrKey,
query,
siteId: siteId ?? undefined
})
}
export async function jiraSearchUsers(
settings: RuntimeJiraSettings,
query?: string,
@@ -1,3 +1,4 @@
import { shallow } from 'zustand/shallow'
import {
jiraConnect,
jiraDisconnect,
@@ -39,9 +40,12 @@ function hasJiraStatusChanged(
return (
previous.connected !== next.connected ||
previous.credentialError !== next.credentialError ||
previous.viewer?.email !== next.viewer?.email ||
previous.credentialProtection !== next.credentialProtection ||
previous.activeSiteId !== next.activeSiteId ||
!shallow(previous.viewer, next.viewer) ||
getSelectedJiraSiteId(previous) !== getSelectedJiraSiteId(next) ||
(previous.sites?.length ?? 0) !== (next.sites?.length ?? 0)
(previous.sites?.length ?? 0) !== (next.sites?.length ?? 0) ||
(previous.sites ?? []).some((site, index) => !shallow(site, next.sites?.[index]))
)
}
@@ -67,12 +71,10 @@ export function createJiraConnectionActions(
return
}
const previous = get().jiraStatus
if (hasJiraStatusChanged(previous, status)) {
if (get().jiraStatusContextKey !== contextKey || hasJiraStatusChanged(previous, status)) {
set((state) => jiraStatusUpdate(state, contextKey, status))
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey })
} else if (get().jiraStatusContextKey !== contextKey) {
set({ jiraStatusContextKey: contextKey })
}
} catch {
if (
@@ -0,0 +1,81 @@
import { create } from 'zustand'
import { beforeEach, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { JiraConnectionStatus } from '../../../../shared/jira-types'
import { createJiraSlice } from './jira'
const jiraStatus = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-jira-client', () => ({ jiraStatus }))
function createTestStore() {
return create<AppState>()((...args) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Connection reads use only settings and the fully initialized Jira slice.
return { settings: null, ...createJiraSlice(...args) } as AppState
})
}
function status(accountId: string): JiraConnectionStatus {
return {
connected: true,
viewer: { accountId, email: 'same@example.com', displayName: 'Same name' },
selectedSiteId: 'all',
activeSiteId: 'site-a',
sites: [
{
id: 'site-a',
accountId,
email: 'same@example.com',
displayName: 'Same name',
siteUrl: 'https://jira.example'
}
]
}
}
beforeEach(() => jiraStatus.mockReset())
it('replaces status when the provider context changes even if its visible values are identical', async () => {
const store = createTestStore()
const previous = status('account-a')
const next = status('account-a')
store.setState({
jiraStatus: previous,
jiraStatusChecked: true,
jiraStatusContextKey: 'old-host'
})
jiraStatus.mockResolvedValue(next)
await store.getState().checkJiraConnection()
expect(store.getState().jiraStatus).toBe(next)
expect(store.getState().jiraStatusContextKey).toBe('local#0')
})
it.each(['viewer', 'site', 'active site'] as const)(
'refreshes a changed %s identity on the same host',
async (change) => {
const store = createTestStore()
jiraStatus.mockResolvedValueOnce(status('account-a'))
await store.getState().checkJiraConnection()
const next = status('account-a')
if (change === 'viewer') {
next.viewer = { accountId: 'account-b', email: 'same@example.com', displayName: 'Same name' }
}
if (change === 'site') {
next.sites = status('account-b').sites
}
if (change === 'active site') {
next.activeSiteId = 'site-b'
}
jiraStatus.mockResolvedValueOnce(next)
await store.getState().checkJiraConnection()
expect(store.getState().jiraStatus).toBe(next)
}
)
it('preserves the status reference for an unchanged identity on the same host', async () => {
const store = createTestStore()
const initial = status('account-a')
jiraStatus.mockResolvedValueOnce(initial).mockResolvedValueOnce(status('account-a'))
await store.getState().checkJiraConnection()
await store.getState().checkJiraConnection()
expect(store.getState().jiraStatus).toBe(initial)
})