/** * JiraAssetsClient - Low-level Jira Assets API client for CMDB caching * * This client handles direct API calls to Jira Insight/Assets and provides * methods for fetching, parsing, and updating CMDB objects. */ import { config } from '../config/env.js'; import { logger } from './logger.js'; import { schemaCacheService } from './schemaCacheService.js'; import type { CMDBObject, ObjectReference } from '../generated/jira-types.js'; import type { JiraAssetsObject, JiraAssetsAttribute, JiraAssetsSearchResponse } from '../types/index.js'; import type { ObjectEntry, ObjectAttribute, ObjectAttributeValue, ReferenceValue, ConfluenceValue } from '../domain/jiraAssetsPayload.js'; import { isReferenceValue, isSimpleValue, hasAttributes } from '../domain/jiraAssetsPayload.js'; import { normalizedCacheStore } from './normalizedCacheStore.js'; // ============================================================================= // Types // ============================================================================= /** Error thrown when an object is not found in Jira (404) */ export class JiraObjectNotFoundError extends Error { constructor(public objectId: string) { super(`Object ${objectId} not found in Jira`); this.name = 'JiraObjectNotFoundError'; } } export interface JiraUpdatePayload { objectTypeId?: number; // Optional for updates (PUT) - only needed for creates (POST) attributes: Array<{ objectTypeAttributeId: number; objectAttributeValues: Array<{ value?: string }>; // value can be undefined when clearing }>; } // Lookup maps - will be populated dynamically from database schema let TYPE_ID_TO_NAME: Record = {}; let JIRA_NAME_TO_TYPE: Record = {}; let OBJECT_TYPES_CACHE: Record }> = {}; /** * Initialize lookup maps from database schema */ async function initializeLookupMaps(): Promise { try { const schema = await schemaCacheService.getSchema(); OBJECT_TYPES_CACHE = {}; TYPE_ID_TO_NAME = {}; JIRA_NAME_TO_TYPE = {}; for (const [typeName, typeDef] of Object.entries(schema.objectTypes)) { OBJECT_TYPES_CACHE[typeName] = { jiraTypeId: typeDef.jiraTypeId, name: typeDef.name, attributes: typeDef.attributes.map(attr => ({ jiraId: attr.jiraId, name: attr.name, fieldName: attr.fieldName, type: attr.type, isMultiple: attr.isMultiple, })), }; TYPE_ID_TO_NAME[typeDef.jiraTypeId] = typeName; JIRA_NAME_TO_TYPE[typeDef.name] = typeName; } } catch (error) { logger.error('JiraAssetsClient: Failed to initialize lookup maps', error); } } // ============================================================================= // JiraAssetsClient Implementation // ============================================================================= class JiraAssetsClient { private baseUrl: string; private defaultHeaders: Record; private isDataCenter: boolean | null = null; private serviceAccountToken: string | null = null; // Service account token from .env (for read operations) private requestToken: string | null = null; // User PAT from profile settings (for write operations) constructor() { this.baseUrl = `${config.jiraHost}/rest/insight/1.0`; this.defaultHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; // Initialize service account token from config (for read operations) this.serviceAccountToken = config.jiraServiceAccountToken || null; // User PAT is configured per-user in profile settings // Authorization header is set per-request via setRequestToken() } // ========================================================================== // Request Token Management (for user-context requests) // ========================================================================== setRequestToken(token: string | null): void { this.requestToken = token; } clearRequestToken(): void { this.requestToken = null; } /** * Check if a token is configured for read operations * Uses service account token (primary) or user PAT (fallback) */ hasToken(): boolean { return !!(this.serviceAccountToken || this.requestToken); } /** * Check if user PAT is configured for write operations */ hasUserToken(): boolean { return !!this.requestToken; } // ========================================================================== // API Detection // ========================================================================== private async detectApiType(): Promise { if (this.isDataCenter !== null) return; // Detect based on host URL pattern: // - Jira Cloud uses *.atlassian.net domains // - Everything else (custom domains) is Data Center / on-premise if (config.jiraHost.includes('atlassian.net')) { this.isDataCenter = false; logger.info('JiraAssetsClient: Detected Jira Cloud (Assets API) based on host URL'); } else { this.isDataCenter = true; logger.info('JiraAssetsClient: Detected Jira Data Center (Insight API) based on host URL'); } } /** * Get headers for API requests * @param forWrite - If true, requires user PAT. If false, uses service account token (or user PAT as fallback) */ private getHeaders(forWrite: boolean = false): Record { const headers = { ...this.defaultHeaders }; if (forWrite) { // Write operations require user PAT if (!this.requestToken) { throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); } headers['Authorization'] = `Bearer ${this.requestToken}`; } else { // Read operations: use service account token (primary) or user PAT (fallback) const token = this.serviceAccountToken || this.requestToken; if (!token) { throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.'); } headers['Authorization'] = `Bearer ${token}`; } return headers; } // ========================================================================== // Core API Methods // ========================================================================== /** * Make a request to Jira API * @param endpoint - API endpoint * @param options - Request options * @param forWrite - If true, requires user PAT for write operations */ private async request(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise { const url = `${this.baseUrl}${endpoint}`; logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url} (forWrite: ${forWrite})`); const response = await fetch(url, { ...options, headers: { ...this.getHeaders(forWrite), ...options.headers, }, }); if (!response.ok) { const text = await response.text(); throw new Error(`Jira API error ${response.status}: ${text}`); } return response.json() as Promise; } // ========================================================================== // Public API Methods // ========================================================================== async testConnection(): Promise { // Don't test connection if no token is configured if (!this.hasToken()) { logger.debug('JiraAssetsClient: No token configured, skipping connection test'); return false; } try { await this.detectApiType(); // Test connection by fetching schemas list (no specific schema ID needed) const response = await fetch(`${this.baseUrl}/objectschema/list`, { headers: this.getHeaders(false), // Read operation - uses service account token }); return response.ok; } catch (error) { logger.error('JiraAssetsClient: Connection test failed', error); return false; } } /** * Get raw ObjectEntry for an object (for recursive processing) */ async getObjectEntry(objectId: string): Promise { try { // Include attributes and deep attributes to get full details of referenced objects (including descriptions) const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=2`; const entry = await this.request(url, {}, false) as unknown as ObjectEntry; // Read operation return entry; } catch (error) { // Check if this is a 404 (object not found / deleted) if (error instanceof Error && error.message.includes('404')) { logger.info(`JiraAssetsClient: Object ${objectId} not found in Jira (likely deleted)`); throw new JiraObjectNotFoundError(objectId); } logger.error(`JiraAssetsClient: Failed to get object entry ${objectId}`, error); return null; } } async getObject(objectId: string): Promise { try { const entry = await this.getObjectEntry(objectId); if (!entry) return null; return this.adaptObjectEntryToJiraAssetsObject(entry); } catch (error) { if (error instanceof JiraObjectNotFoundError) { throw error; } logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error); return null; } } async searchObjects( iql: string, page: number = 1, pageSize: number = 50, schemaId?: string ): Promise<{ objects: JiraAssetsObject[]; totalCount: number; hasMore: boolean; referencedObjects?: Array<{ entry: ObjectEntry; typeName: string }>; rawEntries?: ObjectEntry[]; // Raw ObjectEntry format for recursive processing }> { await this.detectApiType(); // Schema ID must be provided explicitly (no default from config) if (!schemaId) { throw new Error('Schema ID is required for searchObjects. Please provide schemaId parameter.'); } const effectiveSchemaId = schemaId; // Use domain types for API requests let payload: { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number; page?: number; pageSize?: number }; if (this.isDataCenter) { // Try modern AQL endpoint first try { const params = new URLSearchParams({ qlQuery: iql, page: page.toString(), resultPerPage: pageSize.toString(), includeAttributes: 'true', includeAttributesDeep: '2', objectSchemaId: effectiveSchemaId, }); payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>(`/aql/objects?${params.toString()}`, {}, false); // Read operation } catch (error) { // Fallback to deprecated IQL endpoint logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`); const params = new URLSearchParams({ iql, page: page.toString(), resultPerPage: pageSize.toString(), includeAttributes: 'true', includeAttributesDeep: '2', objectSchemaId: effectiveSchemaId, }); payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>(`/iql/objects?${params.toString()}`, {}, false); // Read operation } } else { // Jira Cloud uses POST for AQL payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>('/aql/objects', { method: 'POST', body: JSON.stringify({ qlQuery: iql, page, resultPerPage: pageSize, includeAttributes: true, includeAttributesDeep: 2, // Include attributes of referenced objects (e.g., descriptions) objectSchemaId: effectiveSchemaId, }), }, false); // Read operation } // Adapt to legacy response format for backward compatibility const response = this.adaptAssetsPayloadToSearchResponse({ ...payload, page, pageSize }); const totalCount = response.totalFilterCount || response.totalCount || 0; const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount; // Note: referencedObjects extraction removed - recursive extraction now happens in storeObjectTree // via extractNestedReferencedObjects, which processes the entire object tree recursively return { objects: response.objectEntries || [], totalCount, hasMore, referencedObjects: undefined, // No longer used - recursive extraction handles this rawEntries: payload.objectEntries || [], // Return raw entries for recursive processing }; } /** * Recursively extract all nested referenced objects from an object entry * This function traverses the object tree and extracts all referenced objects * at any depth, preventing infinite loops with circular references. * * @param entry - The object entry to extract nested references from * @param processedIds - Set of already processed object IDs (to prevent duplicates and circular refs) * @param maxDepth - Maximum depth to traverse (default: 5) * @param currentDepth - Current depth in the tree (default: 0) * @returns Array of extracted referenced objects with their type names */ extractNestedReferencedObjects( entry: ObjectEntry, processedIds: Set, maxDepth: number = 5, currentDepth: number = 0 ): Array<{ entry: ObjectEntry; typeName: string }> { const result: Array<{ entry: ObjectEntry; typeName: string }> = []; // Prevent infinite recursion if (currentDepth >= maxDepth) { logger.debug(`JiraAssetsClient: [Recursive] Max depth (${maxDepth}) reached for object ${entry.objectKey || entry.id}`); return result; } const entryId = String(entry.id); // Skip if already processed (handles circular references) if (processedIds.has(entryId)) { logger.debug(`JiraAssetsClient: [Recursive] Skipping already processed object ${entry.objectKey || entry.id} (circular reference detected)`); return result; } processedIds.add(entryId); logger.debug(`JiraAssetsClient: [Recursive] Extracting nested references from ${entry.objectKey || entry.id} at depth ${currentDepth}`); // Initialize lookup maps if needed if (Object.keys(TYPE_ID_TO_NAME).length === 0) { // This is async, but we can't make this function async without breaking the call chain // So we'll initialize it before calling this function logger.warn('JiraAssetsClient: TYPE_ID_TO_NAME not initialized, type resolution may fail'); } // Extract referenced objects from attributes if (entry.attributes) { for (const attr of entry.attributes) { for (const val of attr.objectAttributeValues) { if (isReferenceValue(val) && hasAttributes(val.referencedObject)) { const refId = String(val.referencedObject.id); // Skip if already processed if (processedIds.has(refId)) { continue; } const refTypeId = val.referencedObject.objectType?.id; const refTypeName = TYPE_ID_TO_NAME[refTypeId] || JIRA_NAME_TO_TYPE[val.referencedObject.objectType?.name]; if (refTypeName) { logger.debug(`JiraAssetsClient: [Recursive] Found nested reference: ${val.referencedObject.objectKey || refId} of type ${refTypeName} at depth ${currentDepth + 1}`); // Add this referenced object to results result.push({ entry: val.referencedObject as ObjectEntry, typeName: refTypeName, }); // Recursively extract nested references from this referenced object const nested = this.extractNestedReferencedObjects( val.referencedObject as ObjectEntry, processedIds, maxDepth, currentDepth + 1 ); result.push(...nested); } else { logger.debug(`JiraAssetsClient: [Recursive] Could not resolve type name for referenced object ${refId} (typeId: ${refTypeId}, typeName: ${val.referencedObject.objectType?.name})`); } } } } } if (result.length > 0) { logger.debug(`JiraAssetsClient: [Recursive] Extracted ${result.length} nested references from ${entry.objectKey || entry.id} at depth ${currentDepth}`); } return result; } /** * Get the total count of objects for a specific type from Jira Assets * This is more efficient than fetching all objects when you only need the count * @param typeName - Type name (from database, e.g. "ApplicationComponent") * @param schemaId - Optional schema ID (if not provided, uses mapping or default) */ async getObjectCount(typeName: string, schemaId?: string): Promise { // Ensure lookup maps are initialized if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { await initializeLookupMaps(); } const typeDef = OBJECT_TYPES_CACHE[typeName]; if (!typeDef) { logger.warn(`JiraAssetsClient: Unknown type ${typeName}`); return 0; } try { // Get schema ID from mapping service if not provided let effectiveSchemaId = schemaId; if (!effectiveSchemaId) { const { schemaMappingService } = await import('./schemaMappingService.js'); effectiveSchemaId = await schemaMappingService.getSchemaId(typeName); } // Skip if no schema ID is available (object type not configured) if (!effectiveSchemaId || effectiveSchemaId.trim() === '') { logger.debug(`JiraAssetsClient: No schema ID configured for ${typeName}, returning 0`); return 0; } const iql = `objectType = "${typeDef.name}"`; // Use pageSize=1 to minimize data transfer, we only need the totalCount const result = await this.searchObjects(iql, 1, 1, effectiveSchemaId); logger.debug(`JiraAssetsClient: ${typeName} has ${result.totalCount} objects in Jira Assets (schema: ${effectiveSchemaId})`); return result.totalCount; } catch (error) { logger.error(`JiraAssetsClient: Failed to get count for ${typeName}`, error); return 0; } } async getAllObjectsOfType( typeName: string, batchSize: number = 40, schemaId?: string ): Promise<{ objects: JiraAssetsObject[]; referencedObjects: Array<{ entry: ObjectEntry; typeName: string }>; rawEntries?: ObjectEntry[]; // Raw ObjectEntry format for recursive processing }> { // If typeName is a display name (not in cache), use it directly for IQL query // Otherwise, look up the type definition let objectTypeName = typeName; // Try to find in cache first if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { await initializeLookupMaps(); } const typeDef = OBJECT_TYPES_CACHE[typeName]; if (typeDef) { objectTypeName = typeDef.name; // Use the Jira name from cache } else { // Type not in cache - assume typeName is already the Jira display name logger.debug(`JiraAssetsClient: Type ${typeName} not in cache, using as display name directly`); } // Get schema ID from mapping service if not provided let effectiveSchemaId = schemaId; if (!effectiveSchemaId) { const { schemaMappingService } = await import('./schemaMappingService.js'); effectiveSchemaId = await schemaMappingService.getSchemaId(typeName); } if (!effectiveSchemaId) { throw new Error(`No schema ID available for object type ${typeName}`); } const allObjects: JiraAssetsObject[] = []; const rawEntries: ObjectEntry[] = []; // Store raw entries for recursive processing let page = 1; let hasMore = true; while (hasMore) { const iql = `objectType = "${objectTypeName}"`; const result = await this.searchObjects(iql, page, batchSize, effectiveSchemaId); allObjects.push(...result.objects); // Collect raw entries for recursive processing if (result.rawEntries) { rawEntries.push(...result.rawEntries); } hasMore = result.hasMore; page++; } logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects from schema ${effectiveSchemaId} (raw entries: ${rawEntries.length})`); // Note: referencedObjects no longer collected - recursive extraction in storeObjectTree handles nested objects return { objects: allObjects, referencedObjects: [], rawEntries }; } async getUpdatedObjectsSince( since: Date, _batchSize: number = 40 ): Promise { await this.detectApiType(); // Jira Data Center's IQL doesn't support filtering by 'updated' attribute if (this.isDataCenter) { logger.debug('JiraAssetsClient: Incremental sync via IQL not supported on Data Center, skipping'); return []; } // For Jira Cloud, we could use updated >= "date" in IQL const iql = `updated >= "${since.toISOString()}"`; const result = await this.searchObjects(iql, 1, 1000); return result.objects; } async updateObject(objectId: string, payload: JiraUpdatePayload): Promise { // Write operations require user PAT if (!this.hasUserToken()) { throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); } try { logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, { attributeCount: payload.attributes.length, payload: JSON.stringify(payload, null, 2) }); await this.request(`/object/${objectId}`, { method: 'PUT', body: JSON.stringify(payload), }, true); // Write operation - requires user PAT logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`); return true; } catch (error) { logger.error(`JiraAssetsClient: Failed to update object ${objectId}`, error); return false; } } // ========================================================================== // Adapter Functions (temporary - for backward compatibility) // ========================================================================== /** * Adapt ObjectEntry from domain types to legacy JiraAssetsObject type * This is a temporary adapter during migration * Handles both ObjectEntry (domain) and legacy JiraAssetsObject formats */ adaptObjectEntryToJiraAssetsObject(entry: ObjectEntry | JiraAssetsObject | null): JiraAssetsObject | null { if (!entry) return null; // Check if already in legacy format (has 'attributes' as array with JiraAssetsAttribute) if ('attributes' in entry && Array.isArray(entry.attributes) && entry.attributes.length > 0 && 'objectTypeAttributeId' in entry.attributes[0] && !('id' in entry.attributes[0])) { // Validate the legacy format object has required fields const legacyObj = entry as JiraAssetsObject; if (legacyObj.id === null || legacyObj.id === undefined) { logger.warn(`JiraAssetsClient: Legacy object missing id. ObjectKey: ${legacyObj.objectKey}, Label: ${legacyObj.label}`); return null; } if (!legacyObj.objectKey || !String(legacyObj.objectKey).trim()) { logger.warn(`JiraAssetsClient: Legacy object missing objectKey. ID: ${legacyObj.id}, Label: ${legacyObj.label}`); return null; } if (!legacyObj.label || !String(legacyObj.label).trim()) { logger.warn(`JiraAssetsClient: Legacy object missing label. ID: ${legacyObj.id}, ObjectKey: ${legacyObj.objectKey}`); return null; } return legacyObj; } // Convert from ObjectEntry format const domainEntry = entry as ObjectEntry; // Validate required fields before conversion if (domainEntry.id === null || domainEntry.id === undefined) { logger.warn(`JiraAssetsClient: ObjectEntry missing id. ObjectKey: ${domainEntry.objectKey}, Label: ${domainEntry.label}`); return null; } if (!domainEntry.objectKey || !String(domainEntry.objectKey).trim()) { logger.warn(`JiraAssetsClient: ObjectEntry missing objectKey. ID: ${domainEntry.id}, Label: ${domainEntry.label}`); return null; } if (!domainEntry.label || !String(domainEntry.label).trim()) { logger.warn(`JiraAssetsClient: ObjectEntry missing label. ID: ${domainEntry.id}, ObjectKey: ${domainEntry.objectKey}`); return null; } // Convert id - ensure it's a number let objectId: number; if (typeof domainEntry.id === 'string') { const parsed = parseInt(domainEntry.id, 10); if (isNaN(parsed)) { logger.warn(`JiraAssetsClient: ObjectEntry id cannot be parsed as number: ${domainEntry.id}`); return null; } objectId = parsed; } else if (typeof domainEntry.id === 'number') { objectId = domainEntry.id; } else { logger.warn(`JiraAssetsClient: ObjectEntry id has invalid type: ${typeof domainEntry.id}`); return null; } return { id: objectId, objectKey: String(domainEntry.objectKey).trim(), label: String(domainEntry.label).trim(), objectType: domainEntry.objectType, created: domainEntry.created || new Date().toISOString(), updated: domainEntry.updated || new Date().toISOString(), attributes: (domainEntry.attributes || []).map(attr => this.adaptObjectAttributeToJiraAssetsAttribute(attr)), }; } /** * Adapt ObjectAttribute from domain types to legacy JiraAssetsAttribute type */ private adaptObjectAttributeToJiraAssetsAttribute(attr: ObjectAttribute): JiraAssetsAttribute { return { objectTypeAttributeId: attr.objectTypeAttributeId, objectTypeAttribute: undefined, // Not in domain type, will be populated from schema if needed objectAttributeValues: attr.objectAttributeValues.map(val => this.adaptObjectAttributeValue(val)), }; } /** * Adapt ObjectAttributeValue from domain types to legacy format */ private adaptObjectAttributeValue(val: ObjectAttributeValue): { value?: string; displayValue?: string; referencedObject?: { id: number; objectKey: string; label: string }; status?: { name: string }; } { if (isReferenceValue(val)) { const ref = val.referencedObject; return { displayValue: val.displayValue, referencedObject: { id: typeof ref.id === 'string' ? parseInt(ref.id, 10) : ref.id, objectKey: ref.objectKey, label: ref.label, }, }; } if (isSimpleValue(val)) { return { value: String(val.value), displayValue: val.displayValue, }; } // StatusValue, ConfluenceValue, UserValue return { displayValue: val.displayValue, status: 'status' in val ? { name: val.status.name } : undefined, }; } /** * Adapt AssetsPayload (from domain types) to legacy JiraAssetsSearchResponse */ private adaptAssetsPayloadToSearchResponse( payload: { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number; page?: number; pageSize?: number } ): JiraAssetsSearchResponse { return { objectEntries: payload.objectEntries.map(entry => this.adaptObjectEntryToJiraAssetsObject(entry)!).filter(Boolean), totalCount: payload.totalCount || 0, totalFilterCount: payload.totalFilterCount, page: payload.page || 1, pageSize: payload.pageSize || 50, }; } // ========================================================================== // Object Parsing // ========================================================================== async parseObject(jiraObj: JiraAssetsObject): Promise { // Ensure lookup maps are initialized if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { await initializeLookupMaps(); } const typeId = jiraObj.objectType?.id; const typeName = TYPE_ID_TO_NAME[typeId] || JIRA_NAME_TO_TYPE[jiraObj.objectType?.name]; if (!typeName) { // This is expected when repairing broken references - object types may not be configured logger.debug(`JiraAssetsClient: Unknown object type for object ${jiraObj.objectKey || jiraObj.id}: ${jiraObj.objectType?.name} (ID: ${typeId}) - object type not configured, skipping`); return null; } const typeDef = OBJECT_TYPES_CACHE[typeName]; if (!typeDef) { logger.warn(`JiraAssetsClient: Type definition not found for type: ${typeName} (object: ${jiraObj.objectKey || jiraObj.id})`); return null; } // Validate required fields from Jira object if (jiraObj.id === null || jiraObj.id === undefined) { logger.warn(`JiraAssetsClient: Object missing id field. ObjectKey: ${jiraObj.objectKey}, Label: ${jiraObj.label}, Type: ${jiraObj.objectType?.name}`); throw new Error(`Cannot parse Jira object: missing id field`); } if (!jiraObj.objectKey || !String(jiraObj.objectKey).trim()) { logger.warn(`JiraAssetsClient: Object missing objectKey. ID: ${jiraObj.id}, Label: ${jiraObj.label}, Type: ${jiraObj.objectType?.name}`); throw new Error(`Cannot parse Jira object ${jiraObj.id}: missing objectKey`); } if (!jiraObj.label || !String(jiraObj.label).trim()) { logger.warn(`JiraAssetsClient: Object missing label. ID: ${jiraObj.id}, ObjectKey: ${jiraObj.objectKey}, Type: ${jiraObj.objectType?.name}`); throw new Error(`Cannot parse Jira object ${jiraObj.id}: missing label`); } // Ensure we have valid values before creating the result const objectId = String(jiraObj.id || ''); const objectKey = String(jiraObj.objectKey || '').trim(); const label = String(jiraObj.label || '').trim(); // Double-check after conversion (in case String() produced "null" or "undefined") if (!objectId || objectId === 'null' || objectId === 'undefined' || objectId === 'NaN') { logger.error(`JiraAssetsClient: parseObject - invalid id after conversion. Original: ${jiraObj.id}, Converted: ${objectId}`); throw new Error(`Cannot parse Jira object: invalid id after conversion (${objectId})`); } if (!objectKey || objectKey === 'null' || objectKey === 'undefined') { logger.error(`JiraAssetsClient: parseObject - invalid objectKey after conversion. Original: ${jiraObj.objectKey}, Converted: ${objectKey}`); throw new Error(`Cannot parse Jira object: invalid objectKey after conversion (${objectKey})`); } if (!label || label === 'null' || label === 'undefined') { logger.error(`JiraAssetsClient: parseObject - invalid label after conversion. Original: ${jiraObj.label}, Converted: ${label}`); throw new Error(`Cannot parse Jira object: invalid label after conversion (${label})`); } const result: Record = { id: objectId, objectKey: objectKey, label: label, _objectType: typeName, _jiraUpdatedAt: jiraObj.updated || new Date().toISOString(), _jiraCreatedAt: jiraObj.created || new Date().toISOString(), }; // Parse each attribute based on schema // IMPORTANT: Don't allow attributes to overwrite id, objectKey, or label const protectedFields = new Set(['id', 'objectKey', 'label', '_objectType', '_jiraUpdatedAt', '_jiraCreatedAt']); for (const attrDef of typeDef.attributes) { // Skip if this attribute would overwrite a protected field if (protectedFields.has(attrDef.fieldName)) { logger.warn(`JiraAssetsClient: Skipping attribute ${attrDef.fieldName} (${attrDef.name}) - would overwrite protected field`); continue; } const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name); const parsedValue = this.parseAttributeValue(jiraAttr, { type: attrDef.type, isMultiple: attrDef.isMultiple ?? false, // Default to false if not specified fieldName: attrDef.fieldName, }); result[attrDef.fieldName] = parsedValue; // Debug logging for Confluence Space field if (attrDef.fieldName === 'confluenceSpace') { logger.info(`[Confluence Space Debug] Object ${jiraObj.objectKey || jiraObj.id}:`); logger.info(` - Attribute definition: name="${attrDef.name}", jiraId=${attrDef.jiraId}, type="${attrDef.type}"`); logger.info(` - Found attribute: ${jiraAttr ? 'yes' : 'no'}`); if (!jiraAttr) { // Log all available attributes to help debug const availableAttrs = jiraObj.attributes?.map(a => { const attrName = a.objectTypeAttribute?.name || 'unnamed'; return `${attrName} (ID: ${a.objectTypeAttributeId})`; }).join(', ') || 'none'; logger.warn(` - Available attributes (${jiraObj.attributes?.length || 0}): ${availableAttrs}`); // Try to find similar attributes const similarAttrs = jiraObj.attributes?.filter(a => { const attrName = a.objectTypeAttribute?.name || ''; const lowerAttrName = attrName.toLowerCase(); return lowerAttrName.includes('confluence') || lowerAttrName.includes('space'); }); if (similarAttrs && similarAttrs.length > 0) { logger.warn(` - Found similar attributes: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`); } } else { logger.info(` - Raw attribute: ${JSON.stringify(jiraAttr, null, 2)}`); logger.info(` - Parsed value: ${parsedValue} (type: ${typeof parsedValue})`); } } } // Final validation - ensure result has required fields // This should never fail if the code above worked correctly, but it's a safety check const finalId = String(result.id || '').trim(); const finalObjectKey = String(result.objectKey || '').trim(); const finalLabel = String(result.label || '').trim(); if (!finalId || finalId === 'null' || finalId === 'undefined' || finalId === 'NaN') { logger.error(`JiraAssetsClient: parseObject result missing or invalid id after all processing. Result: ${JSON.stringify({ hasId: 'id' in result, hasObjectKey: 'objectKey' in result, hasLabel: 'label' in result, id: result.id, objectKey: result.objectKey, label: result.label, resultKeys: Object.keys(result), jiraObj: { id: jiraObj.id, objectKey: jiraObj.objectKey, label: jiraObj.label, objectType: jiraObj.objectType?.name } })}`); throw new Error(`Failed to parse Jira object: result missing or invalid id (${finalId})`); } if (!finalObjectKey || finalObjectKey === 'null' || finalObjectKey === 'undefined') { logger.error(`JiraAssetsClient: parseObject result missing or invalid objectKey after all processing. Result: ${JSON.stringify({ id: result.id, objectKey: result.objectKey, label: result.label, resultKeys: Object.keys(result) })}`); throw new Error(`Failed to parse Jira object: result missing or invalid objectKey (${finalObjectKey})`); } if (!finalLabel || finalLabel === 'null' || finalLabel === 'undefined') { logger.error(`JiraAssetsClient: parseObject result missing or invalid label after all processing. Result: ${JSON.stringify({ id: result.id, objectKey: result.objectKey, label: result.label, resultKeys: Object.keys(result) })}`); throw new Error(`Failed to parse Jira object: result missing or invalid label (${finalLabel})`); } return result as T; } private findAttribute( attributes: JiraAssetsAttribute[], jiraId: number, name: string ): JiraAssetsAttribute | undefined { // Try by ID first let attr = attributes.find(a => a.objectTypeAttributeId === jiraId); if (attr) return attr; // Try by name attr = attributes.find(a => a.objectTypeAttribute?.name === name || a.objectTypeAttribute?.name?.toLowerCase() === name.toLowerCase() ); return attr; } private parseAttributeValue( jiraAttr: JiraAssetsAttribute | undefined, attrDef: { type: string; isMultiple: boolean; fieldName?: string } ): unknown { if (!jiraAttr?.objectAttributeValues?.length) { return attrDef.isMultiple ? [] : null; } // Convert legacy attribute values to domain types for type guard usage // This allows us to use the type guards while maintaining backward compatibility const values = jiraAttr.objectAttributeValues as unknown as ObjectAttributeValue[]; // Use type guards from domain types // Generic Confluence field detection: check if any value has a confluencePage const hasConfluencePage = values.some(v => 'confluencePage' in v && v.confluencePage); if (hasConfluencePage) { const confluenceVal = values.find(v => 'confluencePage' in v && v.confluencePage) as ConfluenceValue | undefined; if (confluenceVal?.confluencePage?.url) { logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluenceVal.confluencePage.url}`); // For multiple values, return array of URLs; for single, return the URL string if (attrDef.isMultiple) { return values .filter((v): v is ConfluenceValue => 'confluencePage' in v && !!v.confluencePage) .map(v => v.confluencePage.url); } return confluenceVal.confluencePage.url; } // Fallback to displayValue if no URL const displayVal = values[0]?.displayValue; if (displayVal) { logger.info(`[Confluence Field Parse] Using displayValue as fallback for field "${attrDef.fieldName || 'unknown'}": ${displayVal}`); return String(displayVal); } return null; } switch (attrDef.type) { case 'reference': { // Use type guard to filter reference values const refs = values .filter(isReferenceValue) .map(v => ({ objectId: String(v.referencedObject.id), objectKey: v.referencedObject.objectKey, label: v.referencedObject.label, } as ObjectReference)); return attrDef.isMultiple ? refs : refs[0] || null; } case 'text': case 'textarea': case 'url': case 'email': case 'select': case 'user': { // Use type guard for simple values when available, otherwise fall back to legacy format const firstVal = values[0]; let val: string | null = null; if (isSimpleValue(firstVal)) { val = String(firstVal.value); } else { val = firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } // Strip HTML if present if (val && typeof val === 'string' && val.includes('<')) { return this.stripHtml(val); } return val; } case 'integer': { const firstVal = values[0]; if (isSimpleValue(firstVal)) { const val = typeof firstVal.value === 'number' ? firstVal.value : parseInt(String(firstVal.value), 10); return isNaN(val) ? null : val; } const val = (firstVal as any)?.value; return val ? parseInt(String(val), 10) : null; } case 'float': { // Regular float parsing const firstVal = values[0]; if (isSimpleValue(firstVal)) { const val = typeof firstVal.value === 'number' ? firstVal.value : parseFloat(String(firstVal.value)); return isNaN(val) ? null : val; } const val = (firstVal as any)?.value; const displayVal = firstVal?.displayValue; // Try displayValue first, then value if (displayVal !== undefined && displayVal !== null) { const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal); return isNaN(parsed) ? null : parsed; } if (val !== undefined && val !== null) { const parsed = typeof val === 'string' ? parseFloat(val) : Number(val); return isNaN(parsed) ? null : parsed; } return null; } case 'boolean': { const firstVal = values[0]; if (isSimpleValue(firstVal)) { return Boolean(firstVal.value); } const val = (firstVal as any)?.value; return val === 'true' || val === 'Ja'; } case 'date': case 'datetime': { const firstVal = values[0]; if (isSimpleValue(firstVal)) { return String(firstVal.value); } return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } case 'status': { const firstVal = values[0]; if ('status' in firstVal && firstVal.status) { return firstVal.status.name || null; } return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } default: const firstVal = values[0]; if (isSimpleValue(firstVal)) { return String(firstVal.value); } return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } } private stripHtml(html: string): string { return html .replace(/<[^>]*>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim(); } } // Export singleton instance export const jiraAssetsClient = new JiraAssetsClient();