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

349 lines • 17.1 kB
import { getConfig } from '../../config/index.js'; import { prompts } from './prompts.js'; import c from 'chalk'; import fs from "fs-extra"; import * as path from "path"; import sortArray from 'sort-array'; import { arrayUniqueByKey, arrayUniqueByKeys, execCommand, execSfdxJson, extractRegexMatches, getCurrentGitBranch, getGitRepoRoot, getGitRepoUrl, git, gitFetch, uxLog, } from './index.js'; import { GitProvider } from '../gitProvider/index.js'; import { TicketProvider } from '../ticketProvider/index.js'; import { flowDiffToMarkdownForPullRequest } from '../gitProvider/utilsMarkdown.js'; import { getBranchMarkdown, getNotificationButtons, getOrgMarkdown } from './notifUtils.js'; import { NotifProvider, UtilsNotifs } from '../notifProvider/index.js'; import { setConnectionVariables } from './orgUtils.js'; import { WebSocketClient } from '../websocketClient.js'; import { countPackageXmlItems } from './xmlUtils.js'; export async function selectTargetBranch(options = {}) { const gitUrl = await getGitRepoUrl() || ''; const message = options.message || `What will be the target branch of your new User Story ? (the branch where you will make your ${GitProvider.getMergeRequestName(gitUrl)} after the User Story is completed)`; const config = await getConfig('user'); const availableTargetBranches = config.availableTargetBranches || null; // There is only once choice so return it if (availableTargetBranches === null && config.developmentBranch) { uxLog("action", this, c.cyan(`Automatically selected target branch is ${c.green(config.developmentBranch)}`)); return config.developmentBranch; } // Request info to build branch name. ex features/config/MYTASK const response = await prompts([ { type: availableTargetBranches ? 'select' : 'text', name: 'targetBranch', message: c.cyanBright(message), description: availableTargetBranches ? 'Choose the target branch for this operation' : 'Enter the name of the target branch', placeholder: availableTargetBranches ? undefined : 'Ex: integration', choices: availableTargetBranches ? availableTargetBranches.map((branch) => { return { title: branch.includes(',') ? branch.split(',').join(' - ') : branch, value: branch.includes(',') ? branch.split(',')[0] : branch, }; }) : [], initial: config.developmentBranch || 'integration', }, ]); const targetBranch = response.targetBranch || 'integration'; return targetBranch; } export async function getGitDeltaScope(currentBranch, targetBranch) { try { await gitFetch(['origin', `${targetBranch}:${targetBranch}`]); } catch (e) { uxLog("other", this, `[Warning] Unable to fetch target branch ${targetBranch} to prepare call to sfdx-git-delta\n` + JSON.stringify(e)); } try { await gitFetch(['origin', `${currentBranch}:${currentBranch}`]); } catch (e) { uxLog("other", this, `[Warning] Unable to fetch current branch ${currentBranch} to prepare call to sfdx-git-delta\n` + JSON.stringify(e)); } const logResult = await git().log([`${targetBranch}..${currentBranch}`]); const toCommit = logResult.latest; const mergeBaseCommand = `git merge-base ${targetBranch} ${currentBranch}`; const mergeBaseCommandResult = await execCommand(mergeBaseCommand, this, { fail: true, }); const masterBranchLatestCommit = mergeBaseCommandResult.stdout.replace('\n', '').replace('\r', ''); return { fromCommit: masterBranchLatestCommit, toCommit: toCommit, logResult: logResult }; } export async function callSfdxGitDelta(from, to, outputDir, options = {}) { const packageXmlGitDeltaCommand = `sf sgd:source:delta --from "${from}" --to "${to}" --output ${outputDir} --ignore-whitespace`; const gitDeltaCommandRes = await execSfdxJson(packageXmlGitDeltaCommand, this, { output: true, fail: false, debug: options?.debugMode || false, cwd: await getGitRepoRoot(), }); // Send results to UI if there is one if (WebSocketClient.isAliveWithLwcUI()) { const deltaPackageXml = path.join(outputDir, 'package', 'package.xml'); const deltaPackageXmlExists = await fs.exists(deltaPackageXml); if (deltaPackageXmlExists) { const deltaNumberOfItems = await countPackageXmlItems(deltaPackageXml); if (deltaNumberOfItems > 0) { WebSocketClient.sendReportFileMessage(deltaPackageXml, `Git Delta package.xml (${deltaNumberOfItems})`, "report"); } } const deltaDestructiveChangesXml = path.join(outputDir, 'destructiveChanges', 'destructiveChanges.xml'); const deltaDestructiveChangesXmlExists = await fs.exists(deltaDestructiveChangesXml); if (deltaDestructiveChangesXmlExists) { const deltaDestructiveChangesNumberOfItems = await countPackageXmlItems(deltaDestructiveChangesXml); if (deltaDestructiveChangesNumberOfItems > 0) { WebSocketClient.sendReportFileMessage(deltaDestructiveChangesXml, `Git Delta destructiveChanges.xml (${deltaDestructiveChangesNumberOfItems})`, "report"); } } } return gitDeltaCommandRes; } export function getPullRequestData() { return globalThis.pullRequestData || {}; } export function setPullRequestData(prData) { globalThis.pullRequestData = Object.assign(globalThis.pullRequestData || {}, prData); } export async function computeCommitsSummary(checkOnly, pullRequestInfo = null) { uxLog("action", this, c.cyan('Computing commits summary...')); const currentGitBranch = await getCurrentGitBranch(); let logResults = []; let previousTargetBranchCommit = ""; if (checkOnly || GitProvider.isDeployBeforeMerge()) { const prInfo = await GitProvider.getPullRequestInfo({ useCache: true }); const deltaScope = await getGitDeltaScope(prInfo?.sourceBranch || currentGitBranch || "", prInfo?.targetBranch || process.env.FORCE_TARGET_BRANCH || ""); logResults = [...deltaScope.logResult.all]; previousTargetBranchCommit = deltaScope.fromCommit; } else { const logRes = await git().log([`HEAD^..HEAD`]); previousTargetBranchCommit = "HEAD^"; logResults = [...logRes.all]; } logResults = arrayUniqueByKeys(logResults, ['message', 'body']).reverse(); let commitsSummary = '## Commits summary\n\n'; const manualActions = []; const tickets = []; for (const logResult of logResults) { commitsSummary += '**' + logResult.message + '**, by ' + logResult.author_name; if (logResult.body) { commitsSummary += '<br/>' + logResult.body + '\n\n'; await collectTicketsAndManualActions(currentGitBranch + '\n' + logResult.message + '\n' + logResult.body, tickets, manualActions, { commits: [logResult], }); } else { await collectTicketsAndManualActions(currentGitBranch + '\n' + logResult.message, tickets, manualActions, { commits: [logResult], }); commitsSummary += '\n\n'; } } // Tickets and references can also be in PR description if (pullRequestInfo) { const prText = (pullRequestInfo.title || '') + (pullRequestInfo.description || ''); await collectTicketsAndManualActions(currentGitBranch + '\n' + prText, tickets, manualActions, { pullRequestInfo: pullRequestInfo, }); } // Unify and sort tickets const ticketsSorted = sortArray(arrayUniqueByKey(tickets, 'id'), { by: ['id'], order: ['asc'] }); uxLog("log", this, c.grey(`[TicketProvider] Found ${ticketsSorted.length} tickets in commit bodies`)); // Try to contact Ticketing servers to gather more info await TicketProvider.collectTicketsInfo(ticketsSorted); // Add manual actions in markdown const manualActionsSorted = [...new Set(manualActions)].reverse(); if (manualActionsSorted.length > 0) { let manualActionsMarkdown = '## Manual actions\n\n'; for (const manualAction of manualActionsSorted) { manualActionsMarkdown += '- ' + manualAction + '\n'; } commitsSummary = manualActionsMarkdown + '\n\n' + commitsSummary; } // Add tickets in markdown if (ticketsSorted.length > 0) { let ticketsMarkdown = '## Tickets\n\n'; for (const ticket of ticketsSorted) { if (ticket.foundOnServer) { ticketsMarkdown += '- [' + ticket.id + '](' + ticket.url + ') ' + ticket.subject; if (ticket.statusLabel) { ticketsMarkdown += ' (' + ticket.statusLabel + ')'; } ticketsMarkdown += '\n'; } else { ticketsMarkdown += '- [' + ticket.id + '](' + ticket.url + ')\n'; } } commitsSummary = ticketsMarkdown + '\n\n' + commitsSummary; } // Add Flow diff in Markdown let flowDiffMarkdown = {}; if ((checkOnly || GitProvider.isDeployBeforeMerge()) && !(process.env?.SFDX_DISABLE_FLOW_DIFF === "true")) { const flowList = []; for (const logResult of logResults) { const updatedFiles = await getCommitUpdatedFiles(logResult.hash); for (const updatedFile of updatedFiles) { if (updatedFile.endsWith(".flow-meta.xml")) { if (fs.existsSync(updatedFile)) { const flowName = path.basename(updatedFile, ".flow-meta.xml"); flowList.push(flowName); } else { uxLog("warning", this, c.yellow(`[FlowGitDiff] Unable to find Flow file ${updatedFile} (probably has been deleted)`)); } } } } const flowListUnique = [...new Set(flowList)].sort(); // Truncate flows to the only 30 ones, to avoid flooding the pull request comments let truncatedNb = 0; const maxFlowsToShow = parseInt(process.env?.MAX_FLOW_DIFF_TO_SHOW || "30"); if (flowListUnique.length > maxFlowsToShow) { truncatedNb = flowListUnique.length - maxFlowsToShow; flowListUnique.splice(maxFlowsToShow, flowListUnique.length - maxFlowsToShow); uxLog("warning", this, c.yellow(`[FlowGitDiff] Truncated flow list to 30 flows to avoid flooding Pull Request comments`)); uxLog("warning", this, c.yellow(`[FlowGitDiff] If you want to see the diff of truncated flows, use the VS Code SFDX Hardis extension 😊`)); } flowDiffMarkdown = await flowDiffToMarkdownForPullRequest(flowListUnique, previousTargetBranchCommit, (logResults.at(-1) || logResults[0]).hash, truncatedNb); } return { markdown: commitsSummary, logResults: logResults, manualActions: manualActionsSorted, tickets: ticketsSorted, flowDiffMarkdown: flowDiffMarkdown }; } async function collectTicketsAndManualActions(str, tickets, manualActions, options) { const foundTickets = await TicketProvider.getProvidersTicketsFromString(str, options); tickets.push(...foundTickets); // Extract manual actions if defined const manualActionsRegex = /MANUAL ACTION:(.*)/gm; const manualActionsMatches = await extractRegexMatches(manualActionsRegex, str); manualActions.push(...manualActionsMatches); } export async function getCommitUpdatedFiles(commitHash) { const result = await git().show(["--name-only", "--pretty=format:", commitHash]); // Split the result into lines (file paths) and remove empty lines const files = result.split('\n').filter(file => file.trim() !== '' && fs.existsSync(file)); return files; } export async function buildCheckDeployCommitSummary() { try { const pullRequestInfo = await GitProvider.getPullRequestInfo({ useCache: true }); const commitsSummary = await computeCommitsSummary(true, pullRequestInfo); const prDataCommitsSummary = { commitsSummary: commitsSummary.markdown, flowDiffMarkdown: commitsSummary.flowDiffMarkdown }; setPullRequestData(prDataCommitsSummary); } catch (e3) { uxLog("warning", this, c.yellow('Unable to compute git summary:\n' + e3)); } } export async function handlePostDeploymentNotifications(flags, targetUsername, quickDeploy, delta, debugMode, additionalMessage = "") { const pullRequestInfo = await GitProvider.getPullRequestInfo({ useCache: true }); const attachments = []; try { // Build notification attachments & handle ticketing systems comments const commitsSummary = await collectNotifAttachments(attachments, pullRequestInfo); await TicketProvider.postDeploymentActions(commitsSummary.tickets, flags['target-org']?.getConnection()?.instanceUrl || targetUsername || '', pullRequestInfo); } catch (e4) { uxLog("warning", this, c.yellow('Unable to handle commit info on TicketProvider post deployment actions:\n' + e4.message) + '\n' + c.gray(e4.stack)); } const orgMarkdown = await getOrgMarkdown(flags['target-org']?.getConnection()?.instanceUrl || targetUsername || ''); const branchMarkdown = await getBranchMarkdown(); let notifMessage = `Deployment has been successfully processed from branch ${branchMarkdown} to org ${orgMarkdown}`; notifMessage += quickDeploy ? ' (🚀 quick deployment)' : delta ? ' (🌙 delta deployment)' : ' (🌕 full deployment)'; if (additionalMessage) { notifMessage += '\n\n' + additionalMessage + "\n\n"; } const notifButtons = await getNotificationButtons(); if (pullRequestInfo) { if (debugMode) { uxLog("error", this, c.grey('PR info:\n' + JSON.stringify(pullRequestInfo))); } const prAuthor = pullRequestInfo?.authorName; notifMessage += `\nRelated: <${pullRequestInfo.webUrl}|${pullRequestInfo.title}>` + (prAuthor ? ` by ${prAuthor}` : ''); const prButtonText = 'View Pull Request'; notifButtons.push({ text: prButtonText, url: pullRequestInfo.webUrl }); } else { uxLog("warning", this, c.yellow("WARNING: Unable to get Pull Request info, notif won't have a button URL")); } await setConnectionVariables(flags['target-org']?.getConnection(), true); // Required for some notifications providers like Email await NotifProvider.postNotifications({ type: 'DEPLOYMENT', text: notifMessage, buttons: notifButtons, severity: 'success', attachments: attachments, logElements: [], data: { metric: 0 }, // Todo: if delta used, count the number of items deployed metrics: { DeployedItems: 0, }, }); } async function collectNotifAttachments(attachments, pullRequestInfo) { const commitsSummary = await computeCommitsSummary(false, pullRequestInfo); // Tickets attachment if (commitsSummary.tickets.length > 0) { attachments.push({ text: `*Tickets*\n${commitsSummary.tickets .map((ticket) => { if (ticket.foundOnServer) { let ticketsMarkdown = '• ' + UtilsNotifs.markdownLink(ticket.url, ticket.id) + ' ' + ticket.subject; if (ticket.statusLabel) { ticketsMarkdown += ' (' + ticket.statusLabel + ')'; } return ticketsMarkdown; } else { return '• ' + UtilsNotifs.markdownLink(ticket.url, ticket.id); } }) .join('\n')}`, }); } // Manual actions attachment if (commitsSummary.manualActions.length > 0) { attachments.push({ text: `*Manual actions*\n${commitsSummary.manualActions .map((manualAction) => { return '• ' + manualAction; }) .join('\n')}`, }); } // Commits attachment if (commitsSummary.logResults.length > 0) { attachments.push({ text: `*Commits*\n${commitsSummary.logResults .map((logResult) => { return '• ' + logResult.message + ', by ' + logResult.author_name; }) .join('\n')}`, }); } return commitsSummary; } export function makeFileNameGitCompliant(fileName) { // Remove all characters that are not alphanumeric, underscore, hyphen, space or dot const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9_. -]/g, '_'); return sanitizedFileName; } export function getFileAtCommit(commit, filePath) { return git().show([`${commit}:${filePath}`]); } //# sourceMappingURL=gitUtils.js.map