diff --git a/src/main/ipc/jira-user-search.ts b/src/main/ipc/jira-user-search.ts new file mode 100644 index 00000000000..2a5861aaff8 --- /dev/null +++ b/src/main/ipc/jira-user-search.ts @@ -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) + ) + }) +} diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 0d78eec5f44..6d0db96c1a2 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' import { _resetPreflightCache } from './preflight' import { JiraCancellableRequests } from './jira-cancellable-requests' +import { 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()) { diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 526741afa35..e7fecbe60da 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -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', diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 450b6e19d72..74d185468f3 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments' export { listProjects } from './jira-project-queries' export { listAssignableUsers, + listAssignableUsersForProject, listCreateFields, listIssueTypes, listPriorities, diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts index 90325930afa..070107f9b3f 100644 --- a/src/main/jira/jira-issue-create-metadata.ts +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -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 { 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 { + 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 { + return searchAssignableUsers( + { project: projectIdOrKey }, + query, + siteId, + 'listAssignableUsersForProject' + ) +} diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 06b1e2a8ac8..7498fc819a2 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -76,6 +76,11 @@ export type JiraApi = { query?: string siteId?: string }) => Promise + listAssignableUsersForProject: (args: { + projectIdOrKey: string + query?: string + siteId?: string + }) => Promise searchUsers: (args?: { query?: string; siteId?: string }) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise getProjectStatusOrder: (args: { diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 4b6b991095d..0aaf03521ff 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -46,6 +46,7 @@ export const jiraApi = { title: string description?: string customFields?: Record + userFieldKeys?: string[] }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:createIssue', args), @@ -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), diff --git a/src/renderer/src/components/jira-self-user.test.ts b/src/renderer/src/components/jira-self-user.test.ts new file mode 100644 index 00000000000..6475b1b96bd --- /dev/null +++ b/src/renderer/src/components/jira-self-user.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/components/jira-self-user.ts b/src/renderer/src/components/jira-self-user.ts new file mode 100644 index 00000000000..52ce9ea9912 --- /dev/null +++ b/src/renderer/src/components/jira-self-user.ts @@ -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 +} diff --git a/src/renderer/src/components/jira-user-picker.tsx b/src/renderer/src/components/jira-user-picker.tsx index 249e295f262..a9770dffec6 100644 --- a/src/renderer/src/components/jira-user-picker.tsx +++ b/src/renderer/src/components/jira-user-picker.tsx @@ -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 }): 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) => ( + + ))} {users.length === 0 && !loading ? (

{translate('components.jiraUserPicker.empty', 'No users found')} diff --git a/src/renderer/src/components/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page-jira-create-fields.test.ts index 76707e54a0d..18fcfc918c7 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.test.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page-jira-create-fields.ts index 958cda620ea..07d929555f6 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -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, + assigneeAccountId?: string | null +): { customFields?: Record; 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 + } +} diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx new file mode 100644 index 00000000000..3bff2e62614 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx @@ -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['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() + 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( + + ) + 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( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Assign to me (Remote me)' })) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'remote-me' })) +}) diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx new file mode 100644 index 00000000000..f5091cfe142 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx @@ -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 ( +

+
+ + setNewJiraIssueAssignee(user)} + disabled={newJiraIssueSubmitting} + label={assigneeLabel} + fixedOptions={fixedOptions} + searchUsers={searchAssignableUsers} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/task-page/jira/IssueDialog.tsx b/src/renderer/src/components/task-page/jira/IssueDialog.tsx index b58f806d572..f39beb86078 100644 --- a/src/renderer/src/components/task-page/jira/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/jira/IssueDialog.tsx @@ -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" /> + {jiraCreateFieldsLoading ? (
diff --git a/src/renderer/src/components/use-jira-create-assignee.test.tsx b/src/renderer/src/components/use-jira-create-assignee.test.tsx new file mode 100644 index 00000000000..aa67a7d46ab --- /dev/null +++ b/src/renderer/src/components/use-jira-create-assignee.test.tsx @@ -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() + }) +}) diff --git a/src/renderer/src/components/use-jira-create-assignee.ts b/src/renderer/src/components/use-jira-create-assignee.ts new file mode 100644 index 00000000000..e7565fe2709 --- /dev/null +++ b/src/renderer/src/components/use-jira-create-assignee.ts @@ -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 + } +} diff --git a/src/renderer/src/components/use-task-page-jira-creation-projects.ts b/src/renderer/src/components/use-task-page-jira-creation-projects.ts index 1e8ac8992a7..8c4a6522ba7 100644 --- a/src/renderer/src/components/use-task-page-jira-creation-projects.ts +++ b/src/renderer/src/components/use-task-page-jira-creation-projects.ts @@ -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 diff --git a/src/renderer/src/components/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/use-task-page-jira-issue-creation.ts index c1760e1c11d..af8ce0f7156 100644 --- a/src/renderer/src/components/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/use-task-page-jira-issue-creation.ts @@ -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 & { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4caf882e7da..4456d126c93 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17694,6 +17694,11 @@ } } }, + "jiraIssueAssigneeField": { + "label": "Assignee", + "automatic": "Automatic", + "assignToMe": "Assign to me ({{name}})" + }, "jiraUserPicker": { "select": "Select {{value0}}", "search": "Search users", diff --git a/src/renderer/src/lib/worktree-creation-hook-preparation.ts b/src/renderer/src/lib/worktree-creation-hook-preparation.ts index a8ad413d6c9..4132e3fa898 100644 --- a/src/renderer/src/lib/worktree-creation-hook-preparation.ts +++ b/src/renderer/src/lib/worktree-creation-hook-preparation.ts @@ -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 } diff --git a/src/renderer/src/runtime/runtime-jira-client.test.ts b/src/renderer/src/runtime/runtime-jira-client.test.ts index 89d2cd4713e..5718f8cfff3 100644 --- a/src/renderer/src/runtime/runtime-jira-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-client.test.ts @@ -6,6 +6,7 @@ import { jiraGetIssue, jiraIssueComments, jiraListAssignableUsers, + jiraListAssignableUsersForProject, jiraLookupIssueSummary, jiraReadStatus, jiraSearchIssues, @@ -24,6 +25,7 @@ type RuntimeSubscribeCallbacks = Parameters { 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) => { diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index f3388a95291..2d931414b18 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -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' diff --git a/src/renderer/src/runtime/runtime-jira-user-fields-client.ts b/src/renderer/src/runtime/runtime-jira-user-fields-client.ts index 8b595fb1a97..4ac1bec36e2 100644 --- a/src/renderer/src/runtime/runtime-jira-user-fields-client.ts +++ b/src/renderer/src/runtime/runtime-jira-user-fields-client.ts @@ -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 { + 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, diff --git a/src/renderer/src/store/slices/jira-connection-actions.ts b/src/renderer/src/store/slices/jira-connection-actions.ts index 635b1a6a9cf..8eab27c758e 100644 --- a/src/renderer/src/store/slices/jira-connection-actions.ts +++ b/src/renderer/src/store/slices/jira-connection-actions.ts @@ -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 ( diff --git a/src/renderer/src/store/slices/jira-connection-identity.test.ts b/src/renderer/src/store/slices/jira-connection-identity.test.ts new file mode 100644 index 00000000000..8540953ec44 --- /dev/null +++ b/src/renderer/src/store/slices/jira-connection-identity.test.ts @@ -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()((...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) +})