UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

313 lines (308 loc) • 15.5 kB
/* jscpd:ignore-start */ import { SfCommand, Flags, optionalOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core'; import { Messages, SfError } from '@salesforce/core'; import { generateCsvFile, generateReportPath } from '../../../common/utils/filesUtils.js'; import { soqlQuery } from '../../../common/utils/apiUtils.js'; import axios from 'axios'; import c from 'chalk'; import { uxLog } from '../../../common/utils/index.js'; import { glob } from 'glob'; import { GLOB_IGNORE_PATTERNS } from '../../../common/utils/projectUtils.js'; import { prompts } from '../../../common/utils/prompts.js'; import fs from 'fs-extra'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); /* jscpd:ignore-end */ export default class ServiceNowReport extends SfCommand { static title = 'ServiceNow Report'; static description = `This command retrieves user stories from Salesforce and enriches them with data from ServiceNow. Define the following environment variables (in CICD variables or locally in a **.env** file): - SERVICENOW_URL: The base URL of the ServiceNow API (ex: https://your-instance.service-now.com/) - SERVICENOW_USERNAME: The username for ServiceNow API authentication. - SERVICENOW_PASSWORD: The password for ServiceNow API authentication. You also need to define JSON configuration file(e) in folder **config/user-stories/** Example: \`\`\`json { "userStoriesConfig": { "fields": [ "Id", "Name", "Ticket_Number__c", "copado__User_Story_Title__c", "CreatedBy.Name", "copado__Release__r.Name", "copado__Environment__r.Name" ], "table": "copado__User_Story__c", "where": "copado__Environment__r.Name ='UAT'", "whereChoices": { "UAT all": "copado__Environment__r.Name ='UAT'", "UAT postponed": "copado__Environment__r.Name ='UAT' AND copado__Release__r.Name = 'postponed'", "UAT in progress": "copado__Environment__r.Name ='UAT' AND copado__Release__r.Name != 'postponed' AND copado__Release__r.Name != 'cancelled'" }, "orderBy": "Ticket_Number__c ASC", "ticketField": "Ticket_Number__c", "reportFields": [ { "key": "US Name", "path": "Name" }, { "key": "US SN Identifier", "path": "Ticket_Number__c" }, { "key": "US Title", "path": "copado__User_Story_Title__c" }, { "key": "US Created By", "path": "CreatedBy.Name" }, { "key": "US Environment", "path": "copado__Environment__r.Name" }, { "key": "US Release", "path": "copado__Release__r.Name" }, { "key": "SN Identifier", "path": "serviceNowInfo.number", "default": "NOT FOUND" }, { "key": "SN Title", "path": "serviceNowInfo.short_description", "default": "NOT FOUND" }, { "key": "SN Status", "path": "serviceNowInfo.state", "default": "NOT FOUND" }, { "key": "SN Created By", "path": "serviceNowInfo.sys_created_by", "default": "NOT FOUND" }, { "key": "SN URL", "special": "serviceNowTicketUrl" } ] }, "serviceNowConfig": { "tables": [ { "tableName": "demand" }, { "tableName": "incident" } ] } } \`\`\` `; static examples = ['$ sf hardis:misc:servicenow-report']; /* jscpd:ignore-start */ static flags = { config: Flags.string({ char: 'c', description: 'Path to JSON config file containing user stories and ServiceNow configuration', }), 'where-choice': Flags.string({ char: 'w', description: 'Where selection for user stories. If not provided, you will be prompted to select one from the config file.', }), outputfile: Flags.string({ char: 'f', description: 'Force the path and name of output report file. Must end with .csv', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), 'target-org': optionalOrgFlagWithDeprecations, }; /* jscpd:ignore-end */ static supportsDevhubUsername = false; // Set this to true if your command requires a project workspace; 'requiresProject' is false by default static requiresProject = true; configFile; whereChoice; outputFile; outputFilesRes = {}; userStories = []; results = []; userStoriesConfig = { fields: [ 'Id', 'Name', 'Ticket_Number__c', 'copado__User_Story_Title__c', 'CreatedBy.Name', 'copado__Release__r.Name', 'copado__Environment__r.Name', ], table: 'copado__User_Story__c', where: "copado__Environment__r.Name ='UAT'", whereChoices: { 'UAT all': "copado__Environment__r.Name ='UAT'", 'UAT postponed': "copado__Environment__r.Name ='UAT' AND copado__Release__r.Name = 'postponed'", 'UAT in progress': "copado__Environment__r.Name ='UAT' AND copado__Release__r.Name != 'postponed' AND copado__Release__r.Name != 'cancelled'" }, orderBy: 'Ticket_Number__c ASC', ticketField: 'Ticket_Number__c', reportFields: [ { key: 'US Name', path: 'Name' }, { key: 'US SN Identifier', path: 'Ticket_Number__c' }, { key: 'US Title', path: 'copado__User_Story_Title__c' }, { key: 'US Created By', path: 'CreatedBy.Name' }, { key: 'US Environment', path: 'copado__Environment__r.Name' }, { key: 'US Release', path: 'copado__Release__r.Name' }, { key: 'SN Identifier', path: 'serviceNowInfo.number', default: 'NOT FOUND' }, { key: 'SN Title', path: 'serviceNowInfo.short_description', default: 'NOT FOUND' }, { key: 'SN Status', path: 'serviceNowInfo.state', default: 'NOT FOUND' }, { key: 'SN Created By', path: 'serviceNowInfo.sys_created_by', default: 'NOT FOUND' }, { key: 'SN URL', special: 'serviceNowTicketUrl' } ] }; serviceNowConfig = { tables: [ { tableName: 'demand' }, { tableName: 'incident' }, ] }; async run() { const { flags } = await this.parse(ServiceNowReport); this.configFile = flags.config; this.whereChoice = flags['where-choice']; this.outputFile = flags.outputfile || null; await this.initializeConfiguration(); const conn = flags['target-org']?.getConnection(); // List user stories matching with criteria const ticketNumbers = await this.fetchUserStories(conn); // Get matching demands and incidents from ServiceNow API with axios using ticket numbers const { serviceNowUrl, serviceNowApiOptions } = this.getServiceNowConfig(); // Check each service now table to get the tickets infos for (const table of this.serviceNowConfig.tables) { const serviceNowApiResource = `/api/now/table/${table.tableName}`; const serviceNowApiQuery = `?sysparm_query=numberIN${ticketNumbers.join(',')}&sysparm_display_value=true`; const serviceNowApiUrlWithQuery = `${serviceNowUrl}${serviceNowApiResource}${serviceNowApiQuery}`; // Make API call to ServiceNow uxLog(this, `Fetching Service now using query: ${serviceNowApiUrlWithQuery}`); let serviceNowApiRes; try { serviceNowApiRes = await axios.get(serviceNowApiUrlWithQuery, serviceNowApiOptions); } catch (error) { uxLog(this, c.red(`ServiceNow API call failed: ${error.message}\n${JSON.stringify(error?.response?.data || {})}`)); continue; } // Complete user stories with ServiceNow data const serviceNowRecords = serviceNowApiRes.data.result; uxLog(this, `ServiceNow API call succeeded: ${serviceNowRecords.length} records found`); for (const userStory of this.userStories) { const ticketNumber = userStory?.[this.userStoriesConfig.ticketField]; const serviceNowRecord = serviceNowRecords.find((record) => record.number === ticketNumber); if (serviceNowRecord) { userStory.serviceNowInfo = serviceNowRecord; userStory.serviceNowTableName = table.tableName; } } } // Build final result this.results = this.userStories.map((userStory) => { const serviceNowInfo = userStory.serviceNowInfo || {}; // Build result object dynamically based on config const result = {}; for (const field of this.userStoriesConfig.reportFields) { if (field.special === "serviceNowTicketUrl") { if (!serviceNowInfo.sys_id) { result[field.key] = 'NOT FOUND'; } else { result[field.key] = `${process.env.SERVICENOW_URL}/nav_to.do?uri=/${userStory.serviceNowTableName}.do?sys_id=${serviceNowInfo.sys_id}`; } } else if (field.path) { // Support nested paths like "CreatedBy.Name" or "serviceNowInfo.number" const value = field.path.split('.').reduce((obj, prop) => obj && obj[prop], { ...userStory, serviceNowInfo }); result[field.key] = value !== undefined && value !== null ? value : (field.default ?? 'NOT FOUND'); } } return result; }); uxLog(this, c.grey(JSON.stringify(this.results, null, 2))); // Generate CSV file await this.buildCsvFile(); return { results: this.results, outputFilesRes: this.outputFilesRes }; } getServiceNowConfig() { const serviceNowUrl = process.env.SERVICENOW_URL; if (!serviceNowUrl) { throw new SfError('ServiceNow API URL is not set. Please set SERVICENOW_URL environment variable.'); } const serviceNowApiUser = process.env.SERVICENOW_USERNAME || ''; const serviceNowApiPassword = process.env.SERVICENOW_PASSWORD || ''; const serviceNowApiHeaders = { 'Content-Type': 'application/json', Accept: 'application/json', }; const serviceNowApiOptions = this.buildServiceNowAuthHeaders(serviceNowApiHeaders, serviceNowApiUser, serviceNowApiPassword); return { serviceNowUrl, serviceNowApiOptions }; } async fetchUserStories(conn) { if (this.userStoriesConfig.whereChoices) { // If whereChoices is defined, use the provided whereChoice flag or prompt user to select one if (this.whereChoice) { // If whereChoice is provided, use it directly this.userStoriesConfig.where = this.userStoriesConfig.whereChoices[this.whereChoice]; } else { // If whereChoice is not provided, prompt user to select one uxLog(this, c.yellow('No WHERE choice provided. Please select one from the available choices.')); // If whereChoices is defined, prompt user to select one const whereChoices = Object.keys(this.userStoriesConfig.whereChoices).map((key) => ({ title: key, description: this.userStoriesConfig.whereChoices[key], value: key, })); const whereChoiceRes = await prompts({ type: 'select', message: 'Select a WHERE condition for user stories:', choices: whereChoices, }); this.whereChoice = whereChoiceRes.value; this.userStoriesConfig.where = this.userStoriesConfig.whereChoices[this.whereChoice || '']; } } const userStoriesQuery = `SELECT ${this.userStoriesConfig.fields.join(', ')} FROM ${this.userStoriesConfig.table} WHERE ${this.userStoriesConfig.where} ORDER BY ${this.userStoriesConfig.orderBy}`; const userStoriesRes = await soqlQuery(userStoriesQuery, conn); this.userStories = userStoriesRes.records; // Get list of tickets from user stories const ticketNumbers = userStoriesRes.records.map((record) => record?.[this.userStoriesConfig.ticketField]); return ticketNumbers; } async initializeConfiguration() { if (!this.configFile) { // If no config file is provided, prompt users to select a JSON file in all files found in folder config/user-stories/ const configFiles = await glob('config/user-stories/*.json', { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); if (configFiles.length === 0) { uxLog(this, c.yellow('No configuration files found in config/user-stories/ directory. Using default config...')); } else if (configFiles.length === 1) { this.configFile = configFiles[0]; uxLog(this, `Single config file found: ${this.configFile}`); } else { // If multiple files are found, prompt user to select one const configFileRes = await prompts({ type: 'select', message: 'Multiple configuration files found. Please select one:', choices: configFiles.map((file) => ({ title: file, value: file })), }); this.configFile = configFileRes.value; } } if (this.configFile) { // Load configuration from JSON file try { const configData = await fs.readJSON(this.configFile); this.userStoriesConfig = configData.userStoriesConfig || this.userStoriesConfig; this.serviceNowConfig = configData.serviceNowConfig || this.serviceNowConfig; uxLog(this, `Configuration loaded from ${this.configFile}`); } catch (error) { throw new SfError(`Failed to load configuration file: ${error.message}`); } } } buildServiceNowAuthHeaders(serviceNowApiHeaders, serviceNowApiUser, serviceNowApiPassword) { if (!serviceNowApiUser || !serviceNowApiPassword) { throw new SfError('ServiceNow API credentials are not set. Please set SERVICENOW_USERNAME and SERVICENOW_PASSWORD environment variables.'); } return { headers: serviceNowApiHeaders, auth: { username: serviceNowApiUser, password: serviceNowApiPassword, }, }; } async buildCsvFile() { this.outputFile = await generateReportPath('user-story-report' + (this.whereChoice ? `-${this.whereChoice}` : ''), this.outputFile, { withDate: true }); this.outputFilesRes = await generateCsvFile(this.results, this.outputFile); } } //# sourceMappingURL=servicenow-report.js.map