diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index d12ae0e009f..0d78eec5f44 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -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 ( diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts index 67291c1f197..90325930afa 100644 --- a/src/main/jira/jira-issue-create-metadata.ts +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -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 { const entry = getClients(siteId)[0] if (!entry) { @@ -125,8 +127,11 @@ export async function listPriorities(siteId?: string | null): Promise { const entry = getClients(siteId)[0] if (!entry) { diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts index 555edcefbf2..f14e051f663 100644 --- a/src/main/jira/jira-issue-mutations.ts +++ b/src/main/jira/jira-issue-mutations.ts @@ -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 { const entry = getClients(args.siteId)[0] if (!entry) { @@ -76,6 +82,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise { return searchJiraUsers(query, siteId) } diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 91f5099be3f..087aa25f36e 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -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) { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5180bd0b352..340d9a9c8eb 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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 }) diff --git a/src/renderer/src/components/jira-issue-workspace-chrome.tsx b/src/renderer/src/components/jira-issue-workspace-chrome.tsx index 492e5c7473e..3a868137dc4 100644 --- a/src/renderer/src/components/jira-issue-workspace-chrome.tsx +++ b/src/renderer/src/components/jira-issue-workspace-chrome.tsx @@ -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, diff --git a/src/renderer/src/components/jira-user-picker.tsx b/src/renderer/src/components/jira-user-picker.tsx index 43c799535e7..249e295f262 100644 --- a/src/renderer/src/components/jira-user-picker.tsx +++ b/src/renderer/src/components/jira-user-picker.tsx @@ -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, 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 6c4fc0f97fd..76707e54a0d 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 @@ -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( 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 7e5b8c537e7..958cda620ea 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -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) } diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx index cc11e5981dc..84ef5250c76 100644 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx +++ b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx @@ -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 (
- {isJiraUserCreateField(field) && !field.allowedValues?.length ? ( + {isJiraScalarUserCreateField(field) && !field.allowedValues?.length ? ( + 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, diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 709264f16b8..48c65265c06 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -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,