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
436 lines (431 loc) • 23.1 kB
JavaScript
/* 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, uxLogTable } 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';
import { getNotificationButtons, getOrgMarkdown } from '../../../common/utils/notifUtils.js';
import { NotifProvider } from '../../../common/notifProvider/index.js';
import { setConnectionVariables } from '../../../common/utils/orgUtils.js';
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": "dmn_demand" },
{ "tableName": "incident" }
]
}
}
\`\`\`
`;
static examples = [
'$ sf hardis:misc:servicenow-report',
'$ sf hardis:misc:servicenow-report --config config/user-stories/my-config.json --where-choice "UAT all"'
];
/* 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 = [];
invalidResults = [];
conn;
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();
this.conn = flags['target-org']?.getConnection();
// List user stories matching with criteria
const ticketNumbers = await this.fetchUserStories(this.conn);
// Get matching demands and incidents from ServiceNow API with axios using ticket numbers
await this.completeUserStoriesWithServiceNowInfo(ticketNumbers);
// Build final result
await this.handleResults();
return { results: this.results, outputFilesRes: this.outputFilesRes };
}
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("warning", 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:',
description: 'Choose a predefined WHERE condition to filter user stories',
placeholder: 'Select a condition',
choices: whereChoices,
});
this.whereChoice = whereChoiceRes.value;
this.userStoriesConfig.where = this.userStoriesConfig.whereChoices[this.whereChoice || ''];
}
}
uxLog("action", this, c.cyan(`Fetching user stories from Salesforce...`));
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;
const initialUserStoriesCount = this.userStories.length;
// Duplicate user stories entries if there are multiple ticket numbers separated by ; or , in the ticket field
this.userStories = this.userStories.flatMap((us) => {
const ticketFieldValue = us?.[this.userStoriesConfig.ticketField];
if (ticketFieldValue && (ticketFieldValue.includes(';') || ticketFieldValue.includes(','))) {
const ticketNumbers = ticketFieldValue.split(/[,;]\s*/).filter((tn) => tn);
return ticketNumbers.map((tn) => ({ ...us, [this.userStoriesConfig.ticketField]: tn }));
}
return us;
});
const finalUserStoriesCount = this.userStories.length;
// Get list of tickets from user stories
const ticketNumbers = userStoriesRes.records.map((record) => record?.[this.userStoriesConfig.ticketField]);
// Remove null/undefined and duplicates
const ticketNumbersUnique = [...new Set(ticketNumbers.filter((tn) => tn))];
let message = `${initialUserStoriesCount} user stories fetched from Salesforce, with ${ticketNumbersUnique.length} unique ticket numbers.`;
if (finalUserStoriesCount > initialUserStoriesCount) {
message += ` After splitting multiple ticket numbers, there are ${finalUserStoriesCount} user story entries to process.`;
}
uxLog("action", this, c.cyan(message));
uxLog("log", this, c.grey(`Ticket Numbers:\n${ticketNumbersUnique.map((tn) => `- ${tn}`).join('\n')}`));
return ticketNumbersUnique;
}
async completeUserStoriesWithServiceNowInfo(ticketNumbers) {
const { serviceNowUrl, serviceNowApiOptions } = this.getServiceNowConfig();
// Check each service now table to get the tickets infos
uxLog("action", this, c.cyan(`Fetching matching tickets from ServiceNow...`));
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` +
(table.urlSuffix ? table.urlSuffix : '');
const serviceNowApiUrlWithQuery = `${serviceNowUrl}${serviceNowApiResource}${serviceNowApiQuery}`;
// Make API call to ServiceNow
uxLog("log", this, `Fetching ServiceNow ${table.tableName} table using query: ${serviceNowApiUrlWithQuery}`);
let serviceNowApiRes;
try {
serviceNowApiRes = await axios.get(serviceNowApiUrlWithQuery, serviceNowApiOptions);
}
catch (error) {
uxLog("error", 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;
if (!serviceNowRecords || serviceNowRecords.length === 0) {
uxLog("warning", this, c.yellow(`No ${table.tableName} records found in ServiceNow response.`));
continue;
}
uxLog("success", this, `ServiceNow API call succeeded: ${serviceNowRecords.length} records of table ${table.tableName} have been found`);
// If subRecordFields is defined in config, fetch each sub-record using its URL
if (table.subRecordFields) {
for (const subRecordField of table.subRecordFields) {
for (const record of serviceNowRecords) {
if (record?.[subRecordField]?.link && typeof record[subRecordField].link === 'string') {
try {
const serviceNowSubRecordQuery = await axios.get(record[subRecordField].link, serviceNowApiOptions);
record[subRecordField] = Object.assign(record[subRecordField], serviceNowSubRecordQuery?.data?.result || {});
uxLog("success", this, `ServiceNow sub-record API call succeeded for record ${record.number} field ${subRecordField}`);
}
catch (error) {
uxLog("error", this, c.red(`ServiceNow sub-record API call failed: ${error.message}\n${JSON.stringify(error?.response?.data || {})}`));
}
}
else {
uxLog("warning", this, c.yellow(`No link found for sub-record field ${subRecordField} in record ${record.number}. Skipping sub-record fetch.`));
}
}
}
}
// Match ServiceNow records to user stories based on ticket number
uxLog("action", this, c.cyan(`Matching ServiceNow records with user stories...`));
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;
}
}
}
}
async handleResults() {
uxLog("action", this, c.cyan(`Building final results...`));
this.results = this.userStories.map((userStory) => {
const serviceNowInfo = userStory.serviceNowInfo || {};
// Build result object dynamically based on config
let result = {};
const invalidFields = [];
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');
}
// Check field validity
let isValueValid = true;
// Use a safe hasOwnProperty check to avoid calling Object.prototype methods on the target object.
if (Object.prototype.hasOwnProperty.call(field, 'valid')) {
const fieldValue = result[field.key];
if (field.valid === "NOT NULL") {
isValueValid = fieldValue !== null && fieldValue !== 'NOT FOUND';
}
else {
isValueValid = fieldValue === field.valid;
}
}
// If any field is invalid for this result, mark the result as invalid
if (!isValueValid) {
invalidFields.push(field.key);
}
}
const isValid = invalidFields.length === 0 ? true : false;
const invalidFieldsStr = invalidFields.join(", ");
result = Object.assign({ "Is Valid": isValid, "Invalid Fields": invalidFieldsStr }, result);
return result;
});
uxLog("action", this, c.cyan(`Final results built with ${this.results.length} records.`));
uxLogTable(this, this.results);
// Check results validity
this.invalidResults = this.results.filter((res) => {
return res["Is Valid"] === false;
});
uxLog("action", this, c.cyan(`${this.invalidResults.length} invalid results found.`));
if (this.invalidResults.length > 0) {
uxLog("warning", this, c.yellow(`Listing invalid results below.`));
uxLogTable(this, this.invalidResults);
process.exitCode = 1;
}
// Process result
if (this.results.length > 0) {
// Generate output CSV file
this.outputFile = await generateReportPath('service-now-report', this.outputFile);
this.outputFilesRes = await generateCsvFile(this.results, this.outputFile, { fileTitle: 'ServiceNow cross-report' });
if (this.invalidResults.length > 0) {
const invalidOutputFile = this.outputFile.replace('.csv', '-invalid.csv');
await generateCsvFile(this.invalidResults, invalidOutputFile, { fileTitle: 'ServiceNow cross-report - Invalid Entries' });
}
// Build notification
const orgMarkdown = await getOrgMarkdown(this.conn?.instanceUrl);
const notifButtons = await getNotificationButtons();
const notifSeverity = 'warning';
let notifText = `${this.results.length} ServiceNow report lines have been extracted from ${orgMarkdown}`;
if (this.invalidResults.length > 0) {
notifText += `, including ${this.invalidResults.length} invalid entries`;
}
// Post notif
await setConnectionVariables(this.conn); // Required for some notifications providers like Email
await NotifProvider.postNotifications({
type: 'SERVICENOW_REPORT',
text: notifText,
attachments: [],
buttons: notifButtons,
severity: notifSeverity,
attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile] : [],
logElements: this.results,
data: { metric: this.results.length },
metrics: {}
});
}
}
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 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("warning", 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("other", 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:',
description: 'Choose which configuration file to use for the ServiceNow report',
placeholder: 'Select a config file',
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("other", 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,
},
};
}
}
//# sourceMappingURL=servicenow-report.js.map