fix(jira): scope reporter seed and keep multi-user fields on the text path

Review follow-ups on the create-field shaping:

- Seed only `reporter`. isVisibleJiraCreateField matches every required
  non-system field, so the previous filter also pre-filled required custom
  user pickers (Reviewer, Requested by) that Jira never defaults.
- Skip the seed when the target project is on another site. The viewer
  comes from the active site, and the host shapes against the target's
  client, so a foreign accountId is rejected on Cloud and can silently
  resolve to a different person by username on Server/DC.
- Render JiraUserPicker only for scalar user fields. It holds one user, so
  an array-of-user field collapsed to a single member; those keep the
  existing comma-separated text path, which still reaches toUserFieldValue's
  array branch.

Adds docstrings across the touched Jira functions to satisfy the
docstring-coverage pre-merge check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdMzQN6T3jRVahCK2sNyur
This commit is contained in:
Aldin Saracevic
2026-08-30 18:55:40 -07:00
committed by OrcaWin
co-authored by Claude Opus 5
parent 634478c620
commit df48337d72
15 changed files with 105 additions and 25 deletions
+2
View File
@@ -53,6 +53,7 @@ function normalizeStringArray(value: unknown): string[] | undefined {
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined
}
/** Narrows an untrusted IPC payload to the issue-update fields the host accepts. */
function normalizeIssueUpdate(value: unknown): JiraIssueUpdate | null {
if (!value || typeof value !== 'object') {
return null
@@ -84,6 +85,7 @@ function normalizeIssueUpdate(value: unknown): JiraIssueUpdate | null {
return input
}
/** Registers every `jira:*` IPC handler on the main process. */
export function registerJiraHandlers(): void {
ipcMain.handle('jira:connect', async (_event, args: JiraConnectArgs) => {
if (
+7 -2
View File
@@ -55,6 +55,7 @@ export async function listIssueTypes(
}
}
/** Lists the required create fields for a project and issue type. */
export async function listCreateFields(
projectIdOrKey: string,
issueTypeId: string,
@@ -104,6 +105,7 @@ export async function listCreateFields(
}
}
/** Lists the site's issue priorities. */
export async function listPriorities(siteId?: string | null): Promise<JiraPriority[]> {
const entry = getClients(siteId)[0]
if (!entry) {
@@ -125,8 +127,11 @@ export async function listPriorities(siteId?: string | null): Promise<JiraPriori
}
}
// Reporter and user-picker fields are not limited to assignable users, and no
// issue key exists before create, so neither /user/assignable/search variant fits.
/**
* Searches all users on the site. Reporter and user-picker create fields are not
* limited to assignable users and no issue key exists before create, so neither
* `/user/assignable/search` variant fits. Returns `[]` when browse-users is denied.
*/
export async function searchUsers(query?: string, siteId?: string | null): Promise<JiraUser[]> {
const entry = getClients(siteId)[0]
if (!entry) {
+11 -4
View File
@@ -11,14 +11,19 @@ import { clearToken, getClients, isAuthError } from './client'
import { issueUrl, toBodyText } from './jira-issue-mapping'
import type { JiraRecord } from './jira-record-pages'
// Server/DC identifies users by username (`name`), not accountId;
// mapUser stores the Server username in the accountId slot.
/**
* Wraps a user id in the reference object the site expects: `{accountId}` on
* Cloud, `{name}` on Server/DC, which identifies users by username and whose
* ids `mapUser` stores in the accountId slot.
*/
export function userFieldRef(site: JiraSite, id: string | null): JiraRecord {
return site.authType === 'server' ? { name: id } : { accountId: id }
}
// Jira rejects a bare string for user-typed fields (reporter, user pickers) and
// reports the field as missing, so shape it before it reaches the create body.
/**
* Shapes a user-typed create value (scalar or array) into Jira's user reference
* objects. Jira rejects a bare string here and reports the field as missing.
*/
function toUserFieldValue(site: JiraSite, value: unknown): unknown {
if (typeof value === 'string') {
return userFieldRef(site, value)
@@ -29,6 +34,7 @@ function toUserFieldValue(site: JiraSite, value: unknown): unknown {
return value
}
/** Creates an issue, shaping the customFields keys named by `userFieldKeys`. */
export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreateIssueResult> {
const entry = getClients(args.siteId)[0]
if (!entry) {
@@ -76,6 +82,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
}
}
/** Applies field, assignee, and transition updates to an existing issue. */
export async function updateIssue(
key: string,
updates: JiraIssueUpdate,
+1
View File
@@ -40842,6 +40842,7 @@ export class OrcaRuntimeService {
return listJiraAssignableUsers(key, query, siteId)
}
/** Searches all users on the site, for reporter and user-picker create fields. */
jiraSearchUsers(query?: string, siteId?: string): ReturnType<typeof searchJiraUsers> {
return searchJiraUsers(query, siteId)
}
+1
View File
@@ -105,6 +105,7 @@ const ProjectStatusOrder = z.object({
siteId: OptionalString
})
/** Emits a Jira result over RPC, normalizing it to the shape clients decode. */
function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void {
const payload = JSON.stringify(value)
if (payload.length > JIRA_PAYLOAD_MAX_CHARS) {
+1
View File
@@ -1361,6 +1361,7 @@ export default function TaskPage(): React.JSX.Element {
availableJiraProjects,
jiraConnected,
jiraViewer: jiraConnected ? jiraStatus.viewer : null,
jiraViewerSiteId: jiraConnected ? (jiraStatus.activeSiteId ?? null) : null,
settings,
jiraTaskSourceContext
})
@@ -24,6 +24,7 @@ function jiraStatusClass(categoryKey: string): string {
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
/** Header row for an open Jira issue: key, summary, and workspace actions. */
export function JiraIssueWorkspaceHeader({
displayed,
issueLoading,
@@ -82,6 +83,7 @@ export function JiraIssueWorkspaceHeader({
)
}
/** Status, assignee, and priority controls shown above an open Jira issue. */
export function JiraIssueMetadataBar({
displayed,
pendingField,
@@ -11,6 +11,7 @@ import type { TaskSourceContext } from '../../../shared/task-source-context'
const USER_SEARCH_DEBOUNCE_MS = 250
/** Renders the selectable user rows inside the picker popover. */
export function JiraUserOptionList({
users,
onSelect
@@ -37,8 +38,11 @@ export function JiraUserOptionList({
)
}
// Reporter and user-picker create fields need an accountId, not the display name
// a plain text box would collect; Jira rejects a bare string for user fields.
/**
* Searchable single-user combobox for Jira user fields. These need an accountId,
* not the display name a plain text box would collect, since Jira rejects a bare
* string for user fields.
*/
export function JiraUserPicker({
providerSettings,
siteId,
@@ -7,6 +7,7 @@ import {
getJiraCreateAllowedValueLabel,
getJiraCreateOptionPayload,
getJiraUserCreateFieldKeys,
isJiraScalarUserCreateField,
isJiraUserCreateField,
isVisibleJiraCreateField
} from './task-page-jira-create-fields'
@@ -49,6 +50,25 @@ describe('isJiraUserCreateField', () => {
})
})
describe('isJiraScalarUserCreateField', () => {
it('matches single-user fields the picker can hold', () => {
expect(isJiraScalarUserCreateField(field({ key: 'reporter', schema: { type: 'user' } }))).toBe(
true
)
})
it('rejects array-of-user, which the single-value picker would collapse to one member', () => {
expect(isJiraScalarUserCreateField(field({ schema: { type: 'array', items: 'user' } }))).toBe(
false
)
})
it('ignores non-user fields', () => {
expect(isJiraScalarUserCreateField(field({ schema: { type: 'string' } }))).toBe(false)
expect(isJiraScalarUserCreateField(field())).toBe(false)
})
})
describe('getJiraUserCreateFieldKeys', () => {
it('collects only the user-typed keys for the host to shape', () => {
expect(
@@ -3,12 +3,18 @@ import type { JiraCreateField } from '../../../shared/jira-types'
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
/** Jira's own create screen defaults only this field to the authenticated user. */
export const JIRA_REPORTER_FIELD_KEY = 'reporter'
/** 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)
}
// User pickers (reporter included) carry no allowedValues, so they must be
// recognised by schema type or they fall through to the free-text branch.
/**
* True for any user-typed field, scalar or array. User pickers carry no
* allowedValues, so schema type is the only way to tell them from free text.
*/
export function isJiraUserCreateField(field: JiraCreateField): boolean {
return (
field.schema?.type === 'user' ||
@@ -16,8 +22,19 @@ export function isJiraUserCreateField(field: JiraCreateField): boolean {
)
}
// The host shapes these into Jira's {accountId} / {name} objects; it cannot
// infer which keys hold user ids from the values alone.
/**
* True only for single-user fields. `JiraUserPicker` holds one user, so
* array-of-user fields stay on the comma-separated text path until the dialog
* can collect and submit several.
*/
export function isJiraScalarUserCreateField(field: JiraCreateField): boolean {
return field.schema?.type === 'user'
}
/**
* Collects the user-typed field keys for the host to shape, which it cannot
* infer from the values alone since a user id is just a string.
*/
export function getJiraUserCreateFieldKeys(fields: readonly JiraCreateField[]): string[] {
return fields.filter(isJiraUserCreateField).map((field) => field.key)
}
@@ -10,7 +10,7 @@ import {
} from '@/components/ui/select'
import {
getJiraCreateAllowedValueLabel,
isJiraUserCreateField
isJiraScalarUserCreateField
} from '@/components/task-page-jira-create-fields'
import { JiraUserPicker } from '@/components/jira-user-picker'
import { translate } from '@/i18n/i18n'
@@ -18,6 +18,7 @@ import type { GlobalSettings } from '../../../../../shared/global-settings-types
import type { JiraCreateField, JiraUser } from '../../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
/** Renders the required create fields, choosing a picker, select, or text input per schema. */
export function NewJiraIssueCustomFields({
visibleJiraCreateFields,
newJiraIssueCustomFieldValues,
@@ -47,7 +48,7 @@ export function NewJiraIssueCustomFields({
return (
<div key={field.key} className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">{field.name}</label>
{isJiraUserCreateField(field) && !field.allowedValues?.length ? (
{isJiraScalarUserCreateField(field) && !field.allowedValues?.length ? (
<JiraUserPicker
providerSettings={providerSettings}
siteId={siteId}
@@ -82,6 +82,7 @@ export type NewJiraIssueDialogProps = {
submitShortcutLabel: string
}
/** Dialog for creating a Jira issue and opening a workspace for it. */
export function NewJiraIssueDialog(props: NewJiraIssueDialogProps): React.JSX.Element {
const {
newJiraIssueOpen,
@@ -16,6 +16,7 @@ import type {
} from '../../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
/** Builds the create payload and submits it, flagging which keys hold user ids. */
export function useTaskPageCreateJiraSubmit({
newJiraIssueTargetProject,
newJiraIssueTargetType,
@@ -10,7 +10,8 @@ import {
getJiraProjectSelectionKey
} from '@/components/task-page-jira-project-selection'
import {
isJiraUserCreateField,
JIRA_REPORTER_FIELD_KEY,
isJiraScalarUserCreateField,
isVisibleJiraCreateField
} from '@/components/task-page-jira-create-fields'
import { writeNewJiraIssueDraft } from '@/components/task-page/dialogs/task-creation-draft-writers'
@@ -25,11 +26,13 @@ import type {
} from '../../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
/** Owns new-issue dialog state: project and type selection, create fields, and reporter seeding. */
export function useTaskPageJiraCreateDialog({
selectedJiraSiteId,
availableJiraProjects,
jiraConnected,
jiraViewer,
jiraViewerSiteId,
settings,
jiraTaskSourceContext
}: {
@@ -37,6 +40,7 @@ export function useTaskPageJiraCreateDialog({
availableJiraProjects: JiraProject[]
jiraConnected: boolean
jiraViewer: JiraViewer | null
jiraViewerSiteId: string | null
settings: GlobalSettings | null
jiraTaskSourceContext: TaskSourceContext | null
}) {
@@ -252,29 +256,39 @@ export function useTaskPageJiraCreateDialog({
}
setJiraCreateFields(fields)
// Jira defaults the reporter to the authenticated user; match that so a
// required user field is satisfied without forcing a lookup.
// required reporter is satisfied without forcing a lookup. Only reporter
// is seeded — Jira defaults no other user field, so filling one would
// write a person the user never chose.
if (!jiraViewer) {
return
}
// The viewer belongs to the active site; an id from another site names a
// different person there (or, on Server/DC, silently collides by username).
const targetSiteId = newJiraIssueTargetProject.siteId
if (targetSiteId && jiraViewerSiteId && targetSiteId !== jiraViewerSiteId) {
return
}
const reporterField = fields.find(
(field) =>
field.key === JIRA_REPORTER_FIELD_KEY &&
isVisibleJiraCreateField(field) &&
isJiraScalarUserCreateField(field)
)
if (!reporterField) {
return
}
const seededUser: JiraUser = {
accountId: jiraViewer.accountId,
displayName: jiraViewer.displayName,
email: jiraViewer.email,
avatarUrl: jiraViewer.avatarUrl
}
const seededKeys = fields
.filter((field) => isVisibleJiraCreateField(field) && isJiraUserCreateField(field))
.filter((field) => field.schema?.type !== 'array')
.map((field) => field.key)
if (seededKeys.length === 0) {
return
}
setNewJiraIssueCustomFieldValues((prev) => ({
...Object.fromEntries(seededKeys.map((key) => [key, seededUser.accountId])),
[reporterField.key]: seededUser.accountId,
...prev
}))
setJiraUserFieldSelections((prev) => ({
...Object.fromEntries(seededKeys.map((key) => [key, seededUser])),
[reporterField.key]: seededUser,
...prev
}))
})
@@ -301,6 +315,7 @@ export function useTaskPageJiraCreateDialog({
settings,
jiraConnected,
jiraViewer,
jiraViewerSiteId,
newJiraIssueOpen,
newJiraIssueTargetProject,
newJiraIssueTargetType,
@@ -264,6 +264,7 @@ export async function jiraListPriorities(
: window.api.jira.listPriorities(siteId ? { siteId } : undefined)
}
/** Lists users assignable to an existing issue, via the active runtime. */
export async function jiraListAssignableUsers(
settings: RuntimeJiraSettings,
key: string,
@@ -280,6 +281,7 @@ export async function jiraListAssignableUsers(
: window.api.jira.listAssignableUsers(args)
}
/** Searches Jira users through the active runtime (remote RPC or local IPC). */
export async function jiraSearchUsers(
settings: RuntimeJiraSettings,
query?: string,