Files
cmdb-insight/backend/src/services/jiraAssetsClient.ts
Bert Hausmans cdee0e8819 UI styling improvements: dashboard headers and navigation
- Restore blue PageHeader on Dashboard (/app-components)
- Update homepage (/) with subtle header design without blue bar
- Add uniform PageHeader styling to application edit page
- Fix Rapporten link on homepage to point to /reports overview
- Improve header descriptions spacing for better readability
2026-01-21 03:24:56 +01:00

1051 lines
41 KiB
TypeScript

/**
* 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<number, string> = {};
let JIRA_NAME_TO_TYPE: Record<string, string> = {};
let OBJECT_TYPES_CACHE: Record<string, { jiraTypeId: number; name: string; attributes: Array<{ jiraId: number; name: string; fieldName: string; type: string; isMultiple?: boolean }> }> = {};
/**
* Initialize lookup maps from database schema
*/
async function initializeLookupMaps(): Promise<void> {
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<string, string>;
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<void> {
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<string, string> {
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<T>(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise<T> {
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<T>;
}
// ==========================================================================
// Public API Methods
// ==========================================================================
async testConnection(): Promise<boolean> {
// 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<ObjectEntry | null> {
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<ObjectEntry>(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<JiraAssetsObject | null> {
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<string>,
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<number> {
// 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<JiraAssetsObject[]> {
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<boolean> {
// 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<T extends CMDBObject>(jiraObj: JiraAssetsObject): Promise<T | null> {
// 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<string, unknown> = {
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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
}
// Export singleton instance
export const jiraAssetsClient = new JiraAssetsClient();