mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
committed by
OrcaWin
co-authored by
Claude Opus 5
parent
634478c620
commit
df48337d72
@@ -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 (
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user