- Remove JIRA_SCHEMA_ID from all documentation, config files, and scripts - Update generate-schema.ts to always auto-discover schemas dynamically - Runtime application already discovers schemas via /objectschema/list API - Build script now automatically selects schema with most objects - Remove JIRA_SCHEMA_ID from docker-compose.yml, Azure setup scripts, and all docs - Application is now fully schema-agnostic and discovers schemas automatically
1021 lines
34 KiB
TypeScript
1021 lines
34 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
|
|
/**
|
|
* Schema Generator - Fetches Jira Assets schema DYNAMICALLY and generates:
|
|
* - TypeScript types (jira-types.ts)
|
|
* - Schema metadata (jira-schema.ts)
|
|
* - Database schema (db-schema.sql)
|
|
*
|
|
* This script connects to the Jira Assets API to discover ALL object types
|
|
* and their attributes, ensuring the data model is always in sync with the
|
|
* actual CMDB configuration.
|
|
*
|
|
* Schema Discovery:
|
|
* - Automatically discovers available schemas via /objectschema/list
|
|
* - Selects the schema with the most objects (or the first one if counts unavailable)
|
|
* - The runtime application also discovers schemas dynamically
|
|
*
|
|
* Usage: npm run generate-schema
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import dotenv from 'dotenv';
|
|
|
|
// Load environment variables
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
// Try multiple possible .env locations
|
|
const envPaths = [
|
|
path.resolve(__dirname, '../../.env'), // backend/.env
|
|
path.resolve(__dirname, '../../../.env'), // project root .env
|
|
];
|
|
let envLoaded = '';
|
|
for (const envPath of envPaths) {
|
|
if (fs.existsSync(envPath)) {
|
|
dotenv.config({ path: envPath });
|
|
envLoaded = envPath;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Configuration
|
|
const JIRA_HOST = process.env.JIRA_HOST || '';
|
|
const JIRA_PAT = process.env.JIRA_PAT || '';
|
|
|
|
const OUTPUT_DIR = path.resolve(__dirname, '../src/generated');
|
|
|
|
// =============================================================================
|
|
// Interfaces
|
|
// =============================================================================
|
|
|
|
interface JiraObjectSchema {
|
|
id: number;
|
|
name: string;
|
|
objectSchemaKey: string;
|
|
description?: string;
|
|
objectCount?: number;
|
|
}
|
|
|
|
interface JiraObjectType {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
iconId?: number;
|
|
objectCount?: number;
|
|
parentObjectTypeId?: number;
|
|
objectSchemaId: number;
|
|
inherited?: boolean;
|
|
abstractObjectType?: boolean;
|
|
parentObjectTypeInherited?: boolean;
|
|
}
|
|
|
|
interface JiraAttribute {
|
|
id: number;
|
|
name: string;
|
|
label?: string;
|
|
type: number;
|
|
typeValue?: string;
|
|
defaultType?: { id: number; name: string };
|
|
referenceObjectTypeId?: number;
|
|
referenceObjectType?: { id: number; name: string };
|
|
referenceType?: { id: number; name: string };
|
|
minimumCardinality?: number;
|
|
maximumCardinality?: number;
|
|
editable?: boolean;
|
|
hidden?: boolean;
|
|
system?: boolean;
|
|
options?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface GeneratedAttribute {
|
|
jiraId: number;
|
|
name: string;
|
|
fieldName: string;
|
|
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
|
|
isMultiple: boolean;
|
|
isEditable: boolean;
|
|
isRequired: boolean;
|
|
isSystem: boolean;
|
|
referenceTypeId?: number;
|
|
referenceTypeName?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface GeneratedObjectType {
|
|
jiraTypeId: number;
|
|
name: string;
|
|
typeName: string;
|
|
syncPriority: number;
|
|
objectCount: number;
|
|
attributes: GeneratedAttribute[];
|
|
}
|
|
|
|
// =============================================================================
|
|
// Jira Type Mapping
|
|
// =============================================================================
|
|
|
|
// Jira attribute type mappings (based on Jira Insight/Assets API)
|
|
// See: https://docs.atlassian.com/jira-servicemanagement-docs/REST/5.x/insight/1.0/objecttypeattribute
|
|
const JIRA_TYPE_MAP: Record<number, GeneratedAttribute['type']> = {
|
|
0: 'text', // Default/Text
|
|
1: 'integer', // Integer
|
|
2: 'boolean', // Boolean
|
|
3: 'float', // Double/Float
|
|
4: 'date', // Date
|
|
5: 'datetime', // DateTime
|
|
6: 'url', // URL
|
|
7: 'email', // Email
|
|
8: 'textarea', // Textarea
|
|
9: 'select', // Select
|
|
10: 'reference', // Reference (Object)
|
|
11: 'user', // User
|
|
12: 'reference', // Confluence (treated as reference)
|
|
13: 'reference', // Group (treated as reference)
|
|
14: 'reference', // Version (treated as reference)
|
|
15: 'reference', // Project (treated as reference)
|
|
16: 'status', // Status
|
|
};
|
|
|
|
// Priority types - these sync first as they are reference data
|
|
const PRIORITY_TYPE_NAMES = new Set([
|
|
'Application Component',
|
|
'Server',
|
|
'Flows',
|
|
]);
|
|
|
|
// Reference data types - these sync with lower priority
|
|
const REFERENCE_TYPE_PATTERNS = [
|
|
/Factor$/,
|
|
/Model$/,
|
|
/Type$/,
|
|
/Category$/,
|
|
/Importance$/,
|
|
/Analyse$/,
|
|
/Organisation$/,
|
|
/Function$/,
|
|
];
|
|
|
|
// =============================================================================
|
|
// Jira Schema Fetcher
|
|
// =============================================================================
|
|
|
|
class JiraSchemaFetcher {
|
|
private baseUrl: string;
|
|
private headers: Record<string, string>;
|
|
|
|
constructor(host: string, pat: string) {
|
|
this.baseUrl = `${host}/rest/insight/1.0`;
|
|
this.headers = {
|
|
'Authorization': `Bearer ${pat}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch schema info
|
|
*/
|
|
async fetchSchema(schemaId: string): Promise<JiraObjectSchema | null> {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/objectschema/${schemaId}`, {
|
|
headers: this.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to fetch schema ${schemaId}: ${response.status} ${response.statusText}`);
|
|
const text = await response.text();
|
|
console.error(`Response: ${text}`);
|
|
return null;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error(`Error fetching schema ${schemaId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch ALL object types in the schema
|
|
*/
|
|
async fetchAllObjectTypes(schemaId: string): Promise<JiraObjectType[]> {
|
|
try {
|
|
// Try the objecttypes/flat endpoint first (returns all types in flat structure)
|
|
let response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`, {
|
|
headers: this.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Fallback to regular objecttypes endpoint
|
|
response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes`, {
|
|
headers: this.headers,
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to fetch object types: ${response.status} ${response.statusText}`);
|
|
const text = await response.text();
|
|
console.error(`Response: ${text}`);
|
|
return [];
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Handle both array and object responses
|
|
if (Array.isArray(result)) {
|
|
return result;
|
|
} else if (result.objectTypes) {
|
|
return result.objectTypes;
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error(`Error fetching object types:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch attributes for a specific object type
|
|
*/
|
|
async fetchAttributes(typeId: number): Promise<JiraAttribute[]> {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/objecttype/${typeId}/attributes`, {
|
|
headers: this.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to fetch attributes for type ${typeId}: ${response.status}`);
|
|
return [];
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error(`Error fetching attributes for type ${typeId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all available schemas
|
|
*/
|
|
async listSchemas(): Promise<JiraObjectSchema[]> {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/objectschema/list`, {
|
|
headers: this.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to list schemas: ${response.status} ${response.statusText}`);
|
|
return [];
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Handle both array and object responses
|
|
if (Array.isArray(result)) {
|
|
return result;
|
|
} else if (result && typeof result === 'object' && 'objectschemas' in result) {
|
|
return result.objectschemas || [];
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error(`Error listing schemas:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test the connection
|
|
*/
|
|
async testConnection(): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/objectschema/list`, {
|
|
headers: this.headers,
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Utility Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Convert a string to camelCase while preserving existing casing patterns
|
|
* E.g., "Application Function" -> "applicationFunction"
|
|
* "ICT Governance Model" -> "ictGovernanceModel"
|
|
* "ApplicationFunction" -> "applicationFunction"
|
|
*/
|
|
function toCamelCase(str: string): string {
|
|
// First split on spaces and special chars
|
|
const words = str
|
|
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(w => w.length > 0);
|
|
|
|
if (words.length === 0) return '';
|
|
|
|
// If it's a single word that's already camelCase or PascalCase, just lowercase first char
|
|
if (words.length === 1) {
|
|
const word = words[0];
|
|
return word.charAt(0).toLowerCase() + word.slice(1);
|
|
}
|
|
|
|
// Multiple words - first word lowercase, rest capitalize first letter
|
|
return words
|
|
.map((word, index) => {
|
|
if (index === 0) {
|
|
// First word: if all uppercase (acronym), lowercase it, otherwise just lowercase first char
|
|
if (word === word.toUpperCase() && word.length > 1) {
|
|
return word.toLowerCase();
|
|
}
|
|
return word.charAt(0).toLowerCase() + word.slice(1);
|
|
}
|
|
// Other words: capitalize first letter, keep rest as-is
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Convert a string to PascalCase while preserving existing casing patterns
|
|
* E.g., "Application Function" -> "ApplicationFunction"
|
|
* "ICT Governance Model" -> "IctGovernanceModel"
|
|
* "applicationFunction" -> "ApplicationFunction"
|
|
*/
|
|
function toPascalCase(str: string): string {
|
|
// First split on spaces and special chars
|
|
const words = str
|
|
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(w => w.length > 0);
|
|
|
|
if (words.length === 0) return '';
|
|
|
|
// If it's a single word, just capitalize first letter
|
|
if (words.length === 1) {
|
|
const word = words[0];
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
}
|
|
|
|
// Multiple words - capitalize first letter of each
|
|
return words
|
|
.map(word => {
|
|
// If all uppercase (acronym) and first word, just capitalize first letter
|
|
if (word === word.toUpperCase() && word.length > 1) {
|
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
}
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
function mapJiraType(typeId: number): GeneratedAttribute['type'] {
|
|
return JIRA_TYPE_MAP[typeId] || 'unknown';
|
|
}
|
|
|
|
function determineSyncPriority(typeName: string, objectCount: number): number {
|
|
// Application Component and related main types first
|
|
if (PRIORITY_TYPE_NAMES.has(typeName)) {
|
|
return 1;
|
|
}
|
|
|
|
// Reference data types last
|
|
for (const pattern of REFERENCE_TYPE_PATTERNS) {
|
|
if (pattern.test(typeName)) {
|
|
return 10;
|
|
}
|
|
}
|
|
|
|
// Medium priority for types with more objects
|
|
if (objectCount > 100) {
|
|
return 2;
|
|
}
|
|
if (objectCount > 10) {
|
|
return 5;
|
|
}
|
|
|
|
return 8;
|
|
}
|
|
|
|
function parseAttribute(
|
|
attr: JiraAttribute,
|
|
allTypeConfigs: Map<number, { name: string; typeName: string }>
|
|
): GeneratedAttribute {
|
|
const typeId = attr.type || attr.defaultType?.id || 0;
|
|
let type = mapJiraType(typeId);
|
|
const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1;
|
|
const isEditable = attr.editable !== false && !attr.hidden;
|
|
const isRequired = (attr.minimumCardinality ?? 0) > 0;
|
|
const isSystem = attr.system === true;
|
|
|
|
// CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes!
|
|
// The presence of referenceObjectTypeId is the true indicator of a reference type.
|
|
const refTypeId = attr.referenceObjectTypeId || attr.referenceObjectType?.id || attr.referenceType?.id;
|
|
if (refTypeId) {
|
|
type = 'reference';
|
|
}
|
|
|
|
const result: GeneratedAttribute = {
|
|
jiraId: attr.id,
|
|
name: attr.name,
|
|
fieldName: toCamelCase(attr.name),
|
|
type,
|
|
isMultiple,
|
|
isEditable,
|
|
isRequired,
|
|
isSystem,
|
|
description: attr.description,
|
|
};
|
|
|
|
// Handle reference types - add reference metadata
|
|
if (type === 'reference' && refTypeId) {
|
|
result.referenceTypeId = refTypeId;
|
|
const refConfig = allTypeConfigs.get(refTypeId);
|
|
result.referenceTypeName = refConfig?.typeName ||
|
|
attr.referenceObjectType?.name ||
|
|
attr.referenceType?.name ||
|
|
`Type${refTypeId}`;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Code Generation Functions
|
|
// =============================================================================
|
|
|
|
function generateTypeScriptType(attrType: GeneratedAttribute['type'], isMultiple: boolean, isReference: boolean): string {
|
|
let tsType: string;
|
|
|
|
if (isReference) {
|
|
tsType = 'ObjectReference';
|
|
} else {
|
|
switch (attrType) {
|
|
case 'text':
|
|
case 'textarea':
|
|
case 'url':
|
|
case 'email':
|
|
case 'select':
|
|
case 'user':
|
|
case 'status':
|
|
tsType = 'string';
|
|
break;
|
|
case 'integer':
|
|
case 'float':
|
|
tsType = 'number';
|
|
break;
|
|
case 'boolean':
|
|
tsType = 'boolean';
|
|
break;
|
|
case 'date':
|
|
case 'datetime':
|
|
tsType = 'string'; // ISO date string
|
|
break;
|
|
default:
|
|
tsType = 'unknown';
|
|
}
|
|
}
|
|
|
|
if (isMultiple) {
|
|
return `${tsType}[]`;
|
|
}
|
|
return `${tsType} | null`;
|
|
}
|
|
|
|
function generateTypesFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
|
const lines: string[] = [
|
|
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
'// Generated from Jira Assets Schema via REST API',
|
|
`// Generated at: ${generatedAt.toISOString()}`,
|
|
'//',
|
|
'// Re-generate with: npm run generate-schema',
|
|
'',
|
|
'// =============================================================================',
|
|
'// Base Types',
|
|
'// =============================================================================',
|
|
'',
|
|
'/** Reference to another CMDB object */',
|
|
'export interface ObjectReference {',
|
|
' objectId: string;',
|
|
' objectKey: string;',
|
|
' label: string;',
|
|
' // Optional enriched data from referenced object',
|
|
' factor?: number;',
|
|
'}',
|
|
'',
|
|
'/** Base interface for all CMDB objects */',
|
|
'export interface BaseCMDBObject {',
|
|
' id: string;',
|
|
' objectKey: string;',
|
|
' label: string;',
|
|
' _objectType: string;',
|
|
' _jiraUpdatedAt: string;',
|
|
' _jiraCreatedAt: string;',
|
|
'}',
|
|
'',
|
|
'// =============================================================================',
|
|
'// Object Type Interfaces',
|
|
'// =============================================================================',
|
|
'',
|
|
];
|
|
|
|
for (const objType of objectTypes) {
|
|
lines.push(`/** ${objType.name} (Jira Type ID: ${objType.jiraTypeId}, ${objType.objectCount} objects) */`);
|
|
lines.push(`export interface ${objType.typeName} extends BaseCMDBObject {`);
|
|
lines.push(` _objectType: '${objType.typeName}';`);
|
|
lines.push('');
|
|
|
|
// Group attributes by type
|
|
const scalarAttrs = objType.attributes.filter(a => a.type !== 'reference');
|
|
const refAttrs = objType.attributes.filter(a => a.type === 'reference');
|
|
|
|
if (scalarAttrs.length > 0) {
|
|
lines.push(' // Scalar attributes');
|
|
for (const attr of scalarAttrs) {
|
|
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, false);
|
|
const comment = attr.description ? ` // ${attr.description}` : '';
|
|
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (refAttrs.length > 0) {
|
|
lines.push(' // Reference attributes');
|
|
for (const attr of refAttrs) {
|
|
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, true);
|
|
const comment = attr.referenceTypeName ? ` // -> ${attr.referenceTypeName}` : '';
|
|
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('}');
|
|
lines.push('');
|
|
}
|
|
|
|
// Generate union type
|
|
lines.push('// =============================================================================');
|
|
lines.push('// Union Types');
|
|
lines.push('// =============================================================================');
|
|
lines.push('');
|
|
lines.push('/** Union of all CMDB object types */');
|
|
lines.push('export type CMDBObject =');
|
|
for (let i = 0; i < objectTypes.length; i++) {
|
|
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
|
lines.push(` | ${objectTypes[i].typeName}${suffix}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// Generate type name literal union
|
|
lines.push('/** All valid object type names */');
|
|
lines.push('export type CMDBObjectTypeName =');
|
|
for (let i = 0; i < objectTypes.length; i++) {
|
|
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
|
lines.push(` | '${objectTypes[i].typeName}'${suffix}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// Generate type guards
|
|
lines.push('// =============================================================================');
|
|
lines.push('// Type Guards');
|
|
lines.push('// =============================================================================');
|
|
lines.push('');
|
|
for (const objType of objectTypes) {
|
|
lines.push(`export function is${objType.typeName}(obj: CMDBObject): obj is ${objType.typeName} {`);
|
|
lines.push(` return obj._objectType === '${objType.typeName}';`);
|
|
lines.push('}');
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function generateSchemaFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
|
const lines: string[] = [
|
|
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
'// Generated from Jira Assets Schema via REST API',
|
|
`// Generated at: ${generatedAt.toISOString()}`,
|
|
'//',
|
|
'// Re-generate with: npm run generate-schema',
|
|
'',
|
|
'// =============================================================================',
|
|
'// Schema Type Definitions',
|
|
'// =============================================================================',
|
|
'',
|
|
'export interface AttributeDefinition {',
|
|
' jiraId: number;',
|
|
' name: string;',
|
|
' fieldName: string;',
|
|
" type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';",
|
|
' isMultiple: boolean;',
|
|
' isEditable: boolean;',
|
|
' isRequired: boolean;',
|
|
' isSystem: boolean;',
|
|
' referenceTypeId?: number;',
|
|
' referenceTypeName?: string;',
|
|
' description?: string;',
|
|
'}',
|
|
'',
|
|
'export interface ObjectTypeDefinition {',
|
|
' jiraTypeId: number;',
|
|
' name: string;',
|
|
' typeName: string;',
|
|
' syncPriority: number;',
|
|
' objectCount: number;',
|
|
' attributes: AttributeDefinition[];',
|
|
'}',
|
|
'',
|
|
'// =============================================================================',
|
|
'// Schema Metadata',
|
|
'// =============================================================================',
|
|
'',
|
|
`export const SCHEMA_GENERATED_AT = '${generatedAt.toISOString()}';`,
|
|
`export const SCHEMA_OBJECT_TYPE_COUNT = ${objectTypes.length};`,
|
|
`export const SCHEMA_TOTAL_ATTRIBUTES = ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)};`,
|
|
'',
|
|
'// =============================================================================',
|
|
'// Object Type Definitions',
|
|
'// =============================================================================',
|
|
'',
|
|
'export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {',
|
|
];
|
|
|
|
for (let i = 0; i < objectTypes.length; i++) {
|
|
const objType = objectTypes[i];
|
|
const comma = i < objectTypes.length - 1 ? ',' : '';
|
|
|
|
lines.push(` '${objType.typeName}': {`);
|
|
lines.push(` jiraTypeId: ${objType.jiraTypeId},`);
|
|
lines.push(` name: '${objType.name.replace(/'/g, "\\'")}',`);
|
|
lines.push(` typeName: '${objType.typeName}',`);
|
|
lines.push(` syncPriority: ${objType.syncPriority},`);
|
|
lines.push(` objectCount: ${objType.objectCount},`);
|
|
lines.push(' attributes: [');
|
|
|
|
for (let j = 0; j < objType.attributes.length; j++) {
|
|
const attr = objType.attributes[j];
|
|
const attrComma = j < objType.attributes.length - 1 ? ',' : '';
|
|
|
|
let attrLine = ` { jiraId: ${attr.jiraId}, name: '${attr.name.replace(/'/g, "\\'")}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`;
|
|
|
|
if (attr.referenceTypeId) {
|
|
attrLine += `, referenceTypeId: ${attr.referenceTypeId}, referenceTypeName: '${attr.referenceTypeName}'`;
|
|
}
|
|
if (attr.description) {
|
|
attrLine += `, description: '${attr.description.replace(/'/g, "\\'").replace(/\n/g, ' ')}'`;
|
|
}
|
|
|
|
attrLine += ` }${attrComma}`;
|
|
lines.push(attrLine);
|
|
}
|
|
|
|
lines.push(' ],');
|
|
lines.push(` }${comma}`);
|
|
}
|
|
|
|
lines.push('};');
|
|
lines.push('');
|
|
|
|
// Generate lookup maps
|
|
lines.push('// =============================================================================');
|
|
lines.push('// Lookup Maps');
|
|
lines.push('// =============================================================================');
|
|
lines.push('');
|
|
|
|
// Type ID to name map
|
|
lines.push('/** Map from Jira Type ID to TypeScript type name */');
|
|
lines.push('export const TYPE_ID_TO_NAME: Record<number, string> = {');
|
|
for (const objType of objectTypes) {
|
|
lines.push(` ${objType.jiraTypeId}: '${objType.typeName}',`);
|
|
}
|
|
lines.push('};');
|
|
lines.push('');
|
|
|
|
// Type name to ID map
|
|
lines.push('/** Map from TypeScript type name to Jira Type ID */');
|
|
lines.push('export const TYPE_NAME_TO_ID: Record<string, number> = {');
|
|
for (const objType of objectTypes) {
|
|
lines.push(` '${objType.typeName}': ${objType.jiraTypeId},`);
|
|
}
|
|
lines.push('};');
|
|
lines.push('');
|
|
|
|
// Jira name to TypeScript name map
|
|
lines.push('/** Map from Jira object type name to TypeScript type name */');
|
|
lines.push('export const JIRA_NAME_TO_TYPE: Record<string, string> = {');
|
|
for (const objType of objectTypes) {
|
|
lines.push(` '${objType.name.replace(/'/g, "\\'")}': '${objType.typeName}',`);
|
|
}
|
|
lines.push('};');
|
|
lines.push('');
|
|
|
|
// Helper functions
|
|
lines.push('// =============================================================================');
|
|
lines.push('// Helper Functions');
|
|
lines.push('// =============================================================================');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get attribute definition by type and field name */');
|
|
lines.push('export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {');
|
|
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
|
lines.push(' if (!objectType) return undefined;');
|
|
lines.push(' return objectType.attributes.find(a => a.fieldName === fieldName);');
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get attribute definition by type and Jira attribute ID */');
|
|
lines.push('export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {');
|
|
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
|
lines.push(' if (!objectType) return undefined;');
|
|
lines.push(' return objectType.attributes.find(a => a.jiraId === jiraId);');
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get attribute definition by type and Jira attribute name */');
|
|
lines.push('export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {');
|
|
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
|
lines.push(' if (!objectType) return undefined;');
|
|
lines.push(' return objectType.attributes.find(a => a.name === attrName);');
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get attribute Jira ID by type and attribute name - throws if not found */');
|
|
lines.push('export function getAttributeId(typeName: string, attrName: string): number {');
|
|
lines.push(' const attr = getAttributeByName(typeName, attrName);');
|
|
lines.push(' if (!attr) {');
|
|
lines.push(' throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);');
|
|
lines.push(' }');
|
|
lines.push(' return attr.jiraId;');
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get all reference attributes for a type */');
|
|
lines.push('export function getReferenceAttributes(typeName: string): AttributeDefinition[] {');
|
|
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
|
lines.push(' if (!objectType) return [];');
|
|
lines.push(" return objectType.attributes.filter(a => a.type === 'reference');");
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
lines.push('/** Get all object types sorted by sync priority */');
|
|
lines.push('export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {');
|
|
lines.push(' return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);');
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function generateDatabaseSchema(generatedAt: Date): string {
|
|
const lines: string[] = [
|
|
'-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
'-- Generated from Jira Assets Schema via REST API',
|
|
`-- Generated at: ${generatedAt.toISOString()}`,
|
|
'--',
|
|
'-- Re-generate with: npm run generate-schema',
|
|
'',
|
|
'-- =============================================================================',
|
|
'-- Core Tables',
|
|
'-- =============================================================================',
|
|
'--',
|
|
'-- NOTE: This schema is LEGACY and deprecated.',
|
|
'-- The current system uses the normalized schema defined in',
|
|
'-- backend/src/services/database/normalized-schema.ts',
|
|
'--',
|
|
'-- This file is kept for reference and migration purposes only.',
|
|
'',
|
|
'-- Object relations (references between objects)',
|
|
'CREATE TABLE IF NOT EXISTS object_relations (',
|
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
|
|
' source_id TEXT NOT NULL,',
|
|
' target_id TEXT NOT NULL,',
|
|
' attribute_name TEXT NOT NULL,',
|
|
' source_type TEXT NOT NULL,',
|
|
' target_type TEXT NOT NULL,',
|
|
' UNIQUE(source_id, target_id, attribute_name)',
|
|
');',
|
|
'',
|
|
'-- Sync metadata (tracks sync state)',
|
|
'CREATE TABLE IF NOT EXISTS sync_metadata (',
|
|
' key TEXT PRIMARY KEY,',
|
|
' value TEXT NOT NULL,',
|
|
' updated_at TEXT NOT NULL',
|
|
');',
|
|
'',
|
|
'-- =============================================================================',
|
|
'-- Indices for Performance',
|
|
'-- =============================================================================',
|
|
'',
|
|
'',
|
|
'CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);',
|
|
'CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);',
|
|
'CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);',
|
|
'CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);',
|
|
'CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);',
|
|
'',
|
|
];
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main
|
|
// =============================================================================
|
|
|
|
async function main() {
|
|
const generatedAt = new Date();
|
|
|
|
console.log('');
|
|
console.log('╔════════════════════════════════════════════════════════════════╗');
|
|
console.log('║ CMDB Schema Generator - Jira Assets API ║');
|
|
console.log('╚════════════════════════════════════════════════════════════════╝');
|
|
console.log('');
|
|
|
|
// Validate configuration
|
|
if (!JIRA_HOST) {
|
|
console.error('❌ ERROR: JIRA_HOST environment variable is required');
|
|
console.error(' Set this in your .env file: JIRA_HOST=https://jira.your-domain.com');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!JIRA_PAT) {
|
|
console.error('❌ ERROR: JIRA_PAT environment variable is required');
|
|
console.error(' Set this in your .env file: JIRA_PAT=your-personal-access-token');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (envLoaded) {
|
|
console.log(`🔧 Environment: ${envLoaded}`);
|
|
}
|
|
console.log(`📡 Jira Host: ${JIRA_HOST}`);
|
|
console.log(`📁 Output Dir: ${OUTPUT_DIR}`);
|
|
console.log('');
|
|
|
|
// Ensure output directory exists
|
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
console.log(`📁 Created output directory: ${OUTPUT_DIR}`);
|
|
}
|
|
|
|
const fetcher = new JiraSchemaFetcher(JIRA_HOST, JIRA_PAT);
|
|
|
|
// Test connection
|
|
console.log('🔌 Testing connection to Jira Assets API...');
|
|
const connected = await fetcher.testConnection();
|
|
if (!connected) {
|
|
console.error('❌ Failed to connect to Jira Assets API');
|
|
console.error(' Please check your JIRA_HOST and JIRA_PAT settings');
|
|
process.exit(1);
|
|
}
|
|
console.log('✅ Connection successful');
|
|
console.log('');
|
|
|
|
// Discover schema automatically
|
|
console.log('📋 Discovering available schemas...');
|
|
const schemas = await fetcher.listSchemas();
|
|
|
|
if (schemas.length === 0) {
|
|
console.error('❌ No schemas found');
|
|
console.error(' Please ensure Jira Assets is configured and accessible');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Select the schema with the most objects (or the first one if counts unavailable)
|
|
const schema = schemas.reduce((prev, current) => {
|
|
const prevCount = prev.objectCount || 0;
|
|
const currentCount = current.objectCount || 0;
|
|
return currentCount > prevCount ? current : prev;
|
|
});
|
|
|
|
const selectedSchemaId = schema.id.toString();
|
|
console.log(` Found ${schemas.length} schema(s)`);
|
|
if (schemas.length > 1) {
|
|
console.log(' Available schemas:');
|
|
schemas.forEach(s => {
|
|
const marker = s.id === schema.id ? ' → ' : ' ';
|
|
console.log(`${marker}${s.id}: ${s.name} (${s.objectSchemaKey}) - ${s.objectCount || 0} objects`);
|
|
});
|
|
console.log(` Using schema: ${schema.name} (ID: ${selectedSchemaId})`);
|
|
}
|
|
|
|
console.log(` Schema: ${schema.name} (${schema.objectSchemaKey})`);
|
|
console.log(` Total objects: ${schema.objectCount || 'unknown'}`);
|
|
console.log('');
|
|
|
|
// Fetch ALL object types from the schema
|
|
console.log('📦 Fetching all object types from schema...');
|
|
const allObjectTypes = await fetcher.fetchAllObjectTypes(selectedSchemaId);
|
|
|
|
if (allObjectTypes.length === 0) {
|
|
console.error('❌ No object types found in schema');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(` Found ${allObjectTypes.length} object types`);
|
|
console.log('');
|
|
|
|
// Build a map of all type IDs to names for reference resolution
|
|
const typeConfigs = new Map<number, { name: string; typeName: string }>();
|
|
for (const ot of allObjectTypes) {
|
|
typeConfigs.set(ot.id, {
|
|
name: ot.name,
|
|
typeName: toPascalCase(ot.name),
|
|
});
|
|
}
|
|
|
|
// Fetch attributes for each object type
|
|
console.log('🔍 Fetching attributes for each object type:');
|
|
console.log('');
|
|
|
|
const generatedTypes: GeneratedObjectType[] = [];
|
|
let totalAttributes = 0;
|
|
|
|
for (const objType of allObjectTypes) {
|
|
const typeName = toPascalCase(objType.name);
|
|
process.stdout.write(` ${objType.name.padEnd(50)} `);
|
|
|
|
const attributes = await fetcher.fetchAttributes(objType.id);
|
|
|
|
if (attributes.length === 0) {
|
|
console.log('⚠️ (no attributes)');
|
|
continue;
|
|
}
|
|
|
|
const parsedAttributes = attributes.map(attr => parseAttribute(attr, typeConfigs));
|
|
const syncPriority = determineSyncPriority(objType.name, objType.objectCount || 0);
|
|
|
|
generatedTypes.push({
|
|
jiraTypeId: objType.id,
|
|
name: objType.name,
|
|
typeName,
|
|
syncPriority,
|
|
objectCount: objType.objectCount || 0,
|
|
attributes: parsedAttributes,
|
|
});
|
|
|
|
totalAttributes += attributes.length;
|
|
console.log(`✅ ${attributes.length} attributes`);
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`📊 Summary: ${generatedTypes.length} types, ${totalAttributes} attributes total`);
|
|
console.log('');
|
|
|
|
// Sort by sync priority
|
|
generatedTypes.sort((a, b) => a.syncPriority - b.syncPriority);
|
|
|
|
// Generate files
|
|
console.log('📝 Generating output files:');
|
|
console.log('');
|
|
|
|
// 1. TypeScript types
|
|
const typesContent = generateTypesFile(generatedTypes, generatedAt);
|
|
const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts');
|
|
fs.writeFileSync(typesPath, typesContent, 'utf-8');
|
|
console.log(` ✅ ${typesPath}`);
|
|
|
|
// 2. Schema metadata
|
|
const schemaContent = generateSchemaFile(generatedTypes, generatedAt);
|
|
const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts');
|
|
fs.writeFileSync(schemaPath, schemaContent, 'utf-8');
|
|
console.log(` ✅ ${schemaPath}`);
|
|
|
|
// 3. Database DDL
|
|
const dbContent = generateDatabaseSchema(generatedAt);
|
|
const dbPath = path.join(OUTPUT_DIR, 'db-schema.sql');
|
|
fs.writeFileSync(dbPath, dbContent, 'utf-8');
|
|
console.log(` ✅ ${dbPath}`);
|
|
|
|
console.log('');
|
|
console.log('╔════════════════════════════════════════════════════════════════╗');
|
|
console.log('║ Schema generation complete! ║');
|
|
console.log('╚════════════════════════════════════════════════════════════════╝');
|
|
console.log('');
|
|
console.log(`Generated at: ${generatedAt.toISOString()}`);
|
|
console.log(`Object types: ${generatedTypes.length}`);
|
|
console.log(`Attributes: ${totalAttributes}`);
|
|
console.log('');
|
|
console.log('Next steps:');
|
|
console.log(' 1. Review the generated files in src/generated/');
|
|
console.log(' 2. Rebuild the application: npm run build');
|
|
console.log(' 3. Restart the server to pick up the new schema');
|
|
console.log('');
|
|
}
|
|
|
|
// Run
|
|
main().catch(error => {
|
|
console.error('');
|
|
console.error('❌ Schema generation failed:', error);
|
|
console.error('');
|
|
process.exit(1);
|
|
});
|