d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
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<T> {
|
|
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<string, unknown>): {
|
|
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<string, unknown>
|
|
): Promise<string> {
|
|
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<JsmComment[]> {
|
|
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<JsmComment>
|
|
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<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
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<JsmRequest>
|
|
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<string, unknown>,
|
|
externalId: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocument | null> => {
|
|
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<string, unknown>
|
|
): 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<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
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
|
|
},
|
|
}
|