- 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
1051 lines
41 KiB
TypeScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const jiraAssetsClient = new JiraAssetsClient();
|
|
|