mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
co-authored by
Claude Fable 5
Neil
parent
8b5de39217
commit
fa180a9ee2
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments'
|
||||
export { listProjects } from './jira-project-queries'
|
||||
export {
|
||||
listAssignableUsers,
|
||||
listAssignableUsersForProject,
|
||||
listCreateFields,
|
||||
listIssueTypes,
|
||||
listPriorities,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user