import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { jsmConnectorMeta } from '@/connectors/jsm/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { parseTagDate } from '@/connectors/utils' import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' const logger = createLogger('JsmConnector') const PAGE_SIZE = 50 /** * Allowed `requestStatus` filter values for `GET /rest/servicedeskapi/request`. * When omitted, the JSM API defaults to `ALL_REQUESTS`. */ const VALID_REQUEST_STATUS = ['OPEN_REQUESTS', 'CLOSED_REQUESTS', 'ALL_REQUESTS'] as const type JsmRequestStatus = (typeof VALID_REQUEST_STATUS)[number] /** * Allowed `requestOwnership` filter values for `GET /rest/servicedeskapi/request`. * * This param scopes results to the OAuth user's relationship to each request. When * omitted, the JSM API defaults to `OWNED_REQUESTS` — i.e. only requests the * authenticated user reported. For a knowledge-base sync the user almost always * wants every request in the service desk, so the connector defaults this to * `ALL_REQUESTS` (which the JSM API treats as "owned + participated") rather than * relying on the API's narrower default. */ const VALID_REQUEST_OWNERSHIP = ['OWNED_REQUESTS', 'PARTICIPATED_REQUESTS', 'ALL_REQUESTS'] as const type JsmRequestOwnership = (typeof VALID_REQUEST_OWNERSHIP)[number] /** * Which comments to include in synced documents. */ const VALID_COMMENT_SCOPE = ['none', 'public', 'all'] as const type JsmCommentScope = (typeof VALID_COMMENT_SCOPE)[number] /** * A JSM date object as returned by the Service Desk REST API. The same shape is * used for `createdDate`, `currentStatus.statusDate`, and comment `created`. */ interface JsmDate { iso8601?: string friendly?: string epochMillis?: number } /** * Subset of a JSM customer request returned by `GET /request` and * `GET /request/{issueIdOrKey}`. Only the fields the connector reads are typed. */ interface JsmRequest { issueId?: string issueKey?: string requestTypeId?: string serviceDeskId?: string createdDate?: JsmDate currentStatus?: { status?: string statusCategory?: string statusDate?: JsmDate } reporter?: { displayName?: string emailAddress?: string } requestFieldValues?: Array<{ fieldId?: string label?: string value?: unknown renderedValue?: unknown }> _links?: { web?: string } } /** * A single comment on a JSM request. The JSM API returns the comment `body` as a * plain string containing Jira wiki markup (not an ADF document), so no rich-text * extraction is required. */ interface JsmComment { id?: string body?: string public?: boolean author?: { displayName?: string } created?: JsmDate } /** * Paginated envelope shared by every JSM Service Desk list endpoint. */ interface JsmPage { values?: T[] size?: number isLastPage?: boolean } /** * Reads the resolved sync options off the raw `sourceConfig`, normalizing * enum-like fields to their valid set and clamping the numeric cap. Centralized * so `listDocuments`, `getDocument`, and `validateConfig` agree on defaults. */ function resolveOptions(sourceConfig: Record): { requestStatus: JsmRequestStatus requestOwnership: JsmRequestOwnership requestTypeId: string searchTerm: string commentScope: JsmCommentScope maxRequests: number } { const requestStatus = VALID_REQUEST_STATUS.includes( sourceConfig.requestStatus as JsmRequestStatus ) ? (sourceConfig.requestStatus as JsmRequestStatus) : 'ALL_REQUESTS' const requestOwnership = VALID_REQUEST_OWNERSHIP.includes( sourceConfig.requestOwnership as JsmRequestOwnership ) ? (sourceConfig.requestOwnership as JsmRequestOwnership) : 'ALL_REQUESTS' const commentScope = VALID_COMMENT_SCOPE.includes(sourceConfig.comments as JsmCommentScope) ? (sourceConfig.comments as JsmCommentScope) : 'public' const requestTypeId = typeof sourceConfig.requestTypeId === 'string' ? sourceConfig.requestTypeId.trim() : '' const searchTerm = typeof sourceConfig.searchTerm === 'string' ? sourceConfig.searchTerm.trim() : '' const parsedMax = sourceConfig.maxRequests ? Number(sourceConfig.maxRequests) : 0 const maxRequests = Number.isFinite(parsedMax) && parsedMax > 0 ? Math.floor(parsedMax) : 0 return { requestStatus, requestOwnership, requestTypeId, searchTerm, commentScope, maxRequests } } /** * Extracts a plain-text value for a given request field id (e.g. `summary`, * `description`) from a request's `requestFieldValues`. The JSM API returns * `value` either as a plain string (wiki markup) or, for some rich-text fields, * as an ADF document — both are handled. */ function getFieldText(request: JsmRequest, fieldId: string): string { const field = request.requestFieldValues?.find((f) => f.fieldId === fieldId) if (!field) return '' const { value } = field if (typeof value === 'string') return value if (value && typeof value === 'object') { const adf = extractAdfText(value) if (adf) return adf } return '' } /** * Resolves the best available "change indicator" timestamp for a request. * * The JSM list endpoint does NOT return an updated/last-modified field — only * `createdDate` and `currentStatus.statusDate` are present. We use * `statusDate` (the time the request last changed status) when available, and * fall back to `createdDate`. This is the change signal encoded into the * contentHash. Note: edits that do not change status (e.g. a new comment) are * not reflected here, so such changes may not trigger a re-sync. */ function getChangeIndicator(request: JsmRequest): string { const statusDate = request.currentStatus?.statusDate if (statusDate?.epochMillis != null) return String(statusDate.epochMillis) if (statusDate?.iso8601) return statusDate.iso8601 const created = request.createdDate if (created?.epochMillis != null) return String(created.epochMillis) if (created?.iso8601) return created.iso8601 return '' } /** * Builds a stub ExternalDocument from a request returned by the list endpoint. * Content is deferred — description and comments require a per-request API call * fetched lazily in `getDocument`. The contentHash is metadata-only so it is * identical whether produced here or in `getDocument`. */ function requestToStub(request: JsmRequest, domain: string): ExternalDocument { const issueId = String(request.issueId ?? '') const issueKey = request.issueKey ?? issueId const summary = getFieldText(request, 'summary') || 'Untitled' const status = request.currentStatus?.status const bareDomain = domain .trim() .replace(/^https?:\/\//i, '') .replace(/\/+$/, '') return { externalId: issueId, title: `${issueKey}: ${summary}`, content: '', contentDeferred: true, mimeType: 'text/plain', sourceUrl: request._links?.web || `https://${bareDomain}/browse/${issueKey}`, contentHash: `jsm:${issueId}:${getChangeIndicator(request)}`, metadata: { issueKey, requestTypeId: request.requestTypeId, serviceDeskId: request.serviceDeskId, status, reporter: request.reporter?.displayName, created: request.createdDate?.iso8601, /** * The list endpoint has no true "last updated" field; `statusDate` is the * closest available signal (time of last status change). Mapped to the * `updated` tag and documented as such. */ statusDate: request.currentStatus?.statusDate?.iso8601, }, } } /** * Renders a readable plain-text document from a fully-fetched request and its * comments. Includes summary, description, reporter, status, and comment thread. */ function buildContent(request: JsmRequest, comments: JsmComment[]): string { const parts: string[] = [] const summary = getFieldText(request, 'summary') if (summary) parts.push(summary) const description = getFieldText(request, 'description') if (description) parts.push(description) const status = request.currentStatus?.status if (status) parts.push(`Status: ${status}`) const reporter = request.reporter?.displayName if (reporter) parts.push(`Reporter: ${reporter}`) if (comments.length > 0) { parts.push('Comments:') for (const comment of comments) { const body = (comment.body ?? '').trim() if (!body) continue const author = comment.author?.displayName parts.push(author ? `${author}: ${body}` : body) } } return parts.join('\n\n').trim() } /** * Resolves and caches the Jira cloud ID for a domain across a sync run. */ async function resolveCloudId( domain: string, accessToken: string, syncContext?: Record ): Promise { const cached = syncContext?.cloudId as string | undefined if (cached) return cached const cloudId = await getJiraCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId return cloudId } /** * Fetches comments for a request, following offset pagination until the API * signals `isLastPage`. When `publicOnly` is true the `public=true` filter is * applied so internal/agent-only comments are excluded. */ async function fetchComments( baseUrl: string, accessToken: string, issueIdOrKey: string, publicOnly: boolean ): Promise { const comments: JsmComment[] = [] let start = 0 while (true) { const params = new URLSearchParams({ start: String(start), limit: String(PAGE_SIZE), }) /** * The JSM comment endpoint exposes `public` and `internal` as independent * inclusion filters that both default to `true`. Requesting public-only * therefore requires explicitly disabling `internal` — passing `public=true` * alone would still return agent-only/internal comments. */ if (publicOnly) { params.append('public', 'true') params.append('internal', 'false') } const url = `${baseUrl}/request/${encodeURIComponent(issueIdOrKey)}/comment?${params.toString()}` const response = await fetchWithRetry(url, { method: 'GET', headers: getJsmHeaders(accessToken), }) if (!response.ok) { logger.warn('Failed to fetch JSM comments', { issueIdOrKey, status: response.status, }) break } const data = (await response.json()) as JsmPage const values = data.values ?? [] comments.push(...values) if (data.isLastPage || values.length === 0) break start += values.length } return comments } export const jsmConnector: ConnectorConfig = { ...jsmConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, cursor?: string, syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string const serviceDeskId = sourceConfig.serviceDeskId as string if (!domain || !serviceDeskId) { throw new Error('Domain and service desk ID are required') } const { requestStatus, requestOwnership, requestTypeId, searchTerm, maxRequests } = resolveOptions(sourceConfig) const cloudId = await resolveCloudId(domain, accessToken, syncContext) const baseUrl = getJsmApiBaseUrl(cloudId) /** * `start|collected` is encoded in the cursor so the maxRequests cap holds * across pages even if syncContext is not threaded through by the caller. */ let start = 0 let collectedSoFar = (syncContext?.collectedCount as number | undefined) ?? 0 if (cursor) { const sep = cursor.indexOf('|') if (sep > 0) { const parsedStart = Number(cursor.slice(0, sep)) const parsedCount = Number(cursor.slice(sep + 1)) if (Number.isFinite(parsedStart) && parsedStart >= 0) start = parsedStart if (Number.isFinite(parsedCount) && parsedCount >= 0) collectedSoFar = parsedCount } else { const parsedStart = Number(cursor) if (Number.isFinite(parsedStart) && parsedStart >= 0) start = parsedStart } } const remaining = maxRequests > 0 ? Math.max(0, maxRequests - collectedSoFar) : PAGE_SIZE if (maxRequests > 0 && remaining === 0) { return { documents: [], hasMore: false } } const params = new URLSearchParams({ serviceDeskId, requestStatus, start: String(start), limit: String(Math.min(PAGE_SIZE, remaining)), }) params.append('requestOwnership', requestOwnership) if (requestTypeId) params.append('requestTypeId', requestTypeId) if (searchTerm) params.append('searchTerm', searchTerm) const url = `${baseUrl}/request?${params.toString()}` logger.info('Listing JSM requests', { serviceDeskId, requestStatus, requestOwnership, hasCursor: Boolean(cursor), }) const response = await fetchWithRetry(url, { method: 'GET', headers: getJsmHeaders(accessToken), }) if (!response.ok) { const errorText = await response.text() logger.error('Failed to list JSM requests', { status: response.status, error: errorText }) throw new Error(`Failed to list JSM requests: ${response.status}`) } const data = (await response.json()) as JsmPage let requests = data.values ?? [] let slicedSome = false if (maxRequests > 0 && requests.length > remaining) { slicedSome = true requests = requests.slice(0, remaining) } const documents = requests.map((request) => requestToStub(request, domain)) const newCollected = collectedSoFar + requests.length if (syncContext) syncContext.collectedCount = newCollected const reachedCap = maxRequests > 0 && newCollected >= maxRequests /** * When `maxRequests` truncates the listing before the source is exhausted, * flag the run as capped so the sync engine skips deletion reconciliation — * otherwise unseen requests beyond the cap would be deleted on every sync. * `slicedSome` covers truncation on the final page: requests dropped from * this page still exist even when `isLastPage` is true. (The requested * `limit` never exceeds the remaining budget, so a slice should be * impossible — this is defense in depth against the API over-returning.) */ if (((reachedCap && !data.isLastPage) || slicedSome) && syncContext) { syncContext.listingCapped = true } const hasMore = !data.isLastPage && requests.length > 0 && !reachedCap const nextStart = start + requests.length return { documents, nextCursor: hasMore ? `${nextStart}|${newCollected}` : undefined, hasMore, } }, getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string, syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string const { commentScope } = resolveOptions(sourceConfig) const cloudId = await resolveCloudId(domain, accessToken, syncContext) const baseUrl = getJsmApiBaseUrl(cloudId) const requestUrl = `${baseUrl}/request/${encodeURIComponent(externalId)}?expand=status` const response = await fetchWithRetry(requestUrl, { method: 'GET', headers: getJsmHeaders(accessToken), }) if (!response.ok) { if (response.status === 404) return null if (response.status === 401 || response.status === 403) { logger.warn('Access denied fetching JSM request', { externalId, status: response.status }) return null } throw new Error(`Failed to get JSM request: ${response.status}`) } const request = (await response.json()) as JsmRequest const comments = commentScope === 'none' ? [] : await fetchComments(baseUrl, accessToken, externalId, commentScope === 'public') const stub = requestToStub(request, domain) const content = buildContent(request, comments) return { ...stub, content, contentDeferred: false, } }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string const serviceDeskId = sourceConfig.serviceDeskId as string if (!domain || !serviceDeskId) { return { valid: false, error: 'Domain and service desk ID are required' } } if (sourceConfig.maxRequests) { const max = Number(sourceConfig.maxRequests) if (Number.isNaN(max) || max <= 0) { return { valid: false, error: 'Max requests must be a positive number' } } } try { const cloudId = await getJiraCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) const baseUrl = getJsmApiBaseUrl(cloudId) const url = `${baseUrl}/servicedesk/${encodeURIComponent(serviceDeskId)}` const response = await fetchWithRetry( url, { method: 'GET', headers: getJsmHeaders(accessToken), }, VALIDATE_RETRY_OPTIONS ) if (!response.ok) { if (response.status === 404) { return { valid: false, error: `Service desk "${serviceDeskId}" not found` } } if (response.status === 401 || response.status === 403) { return { valid: false, error: 'Access denied. Check the connected account has access to this service desk.', } } const errorText = await response.text() return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` } } return { valid: true } } catch (error) { return { valid: false, error: toError(error).message || 'Failed to validate configuration' } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.status === 'string') result.status = metadata.status if (typeof metadata.requestTypeId === 'string') result.requestTypeId = metadata.requestTypeId if (typeof metadata.reporter === 'string') result.reporter = metadata.reporter const created = parseTagDate(metadata.created) if (created) result.created = created /** * The list endpoint exposes no true last-updated field; `statusDate` (time * of last status change) is the closest available signal and surfaces under * the "Last Status Change" tag. */ const statusDate = parseTagDate(metadata.statusDate) if (statusDate) result.updated = statusDate return result }, }