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

529 lines (526 loc) • 27 kB
import { GitProviderRoot } from "./gitProviderRoot.js"; import * as azdev from "azure-devops-node-api"; import c from "chalk"; import fs from 'fs-extra'; import { getCurrentGitBranch, getGitRepoUrl, git, isGitRepo, uxLog } from "../utils/index.js"; import * as path from "path"; import { CommentThreadStatus, PullRequestAsyncStatus, PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; import { CONSTANTS } from "../../config/index.js"; import { SfError } from "@salesforce/core"; import { prompts } from "../utils/prompts.js"; export class AzureDevopsProvider extends GitProviderRoot { azureApi; serverUrl; token; attachmentsWorkItemId; attachmentsWorkItemTitle = process.env.AZURE_ATTACHMENTS_WORK_ITEM_TITLE || 'sfdx-hardis tech attachments'; constructor() { super(); // Azure server url must be provided in SYSTEM_COLLECTIONURI. ex: https:/dev.azure.com/mycompany this.serverUrl = process.env.SYSTEM_COLLECTIONURI || ""; // a Personal Access Token must be defined this.token = process.env.CI_SFDX_HARDIS_AZURE_TOKEN || process.env.SYSTEM_ACCESSTOKEN || ""; const authHandler = azdev.getHandlerFromToken(this.token); this.azureApi = new azdev.WebApi(this.serverUrl, authHandler); } static async handleLocalIdentification() { if (!isGitRepo()) { uxLog(this, c.yellow("[Azure Integration] You must be in a git repository context")); return; } if (!process.env.SYSTEM_COLLECTIONURI) { const repoUrl = await getGitRepoUrl() || ""; if (!repoUrl) { uxLog(this, c.yellow("[Azure Integration] An git origin must be set")); return; } const parseUrlRes = this.parseAzureRepoUrl(repoUrl); if (!parseUrlRes) { uxLog(this, c.yellow(`[Azure Integration] Unable to parse ${repoUrl} to get SYSTEM_COLLECTIONURI and BUILD_REPOSITORY_ID`)); return; } process.env.SYSTEM_COLLECTIONURI = parseUrlRes.collectionUri; process.env.SYSTEM_TEAMPROJECT = parseUrlRes.teamProject; process.env.BUILD_REPOSITORY_ID = parseUrlRes.repositoryId; } if (!process.env.SYSTEM_ACCESSTOKEN) { uxLog(this, c.yellow("If you need an Azure Personal Access Token, create one following this documentation: https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows")); uxLog(this, c.yellow("Then please save it in a secured password tracker !")); const accessTokenResp = await prompts({ name: "token", message: "Please input an Azure Personal Access Token (it won't be stored)", type: "text" }); process.env.SYSTEM_ACCESSTOKEN = accessTokenResp.token; } } getLabel() { return "sfdx-hardis Azure Devops connector"; } // Returns current job URL async getCurrentJobUrl() { if (process.env.PIPELINE_JOB_URL) { return process.env.PIPELINE_JOB_URL; } if (process.env.SYSTEM_COLLECTIONURI && process.env.SYSTEM_TEAMPROJECT && process.env.BUILD_BUILDID) { const jobUrl = `${process.env.SYSTEM_COLLECTIONURI}${encodeURIComponent(process.env.SYSTEM_TEAMPROJECT)}/_build/results?buildId=${process.env.BUILD_BUILDID}`; return jobUrl; } uxLog(this, c.yellow(`[Azure DevOps] You need the following variables to be accessible to sfdx-hardis to build current job url: - SYSTEM_COLLECTIONURI - SYSTEM_TEAMPROJECT - BUILD_BUILDID`)); return null; } // Returns current job URL async getCurrentBranchUrl() { if (process.env.SYSTEM_COLLECTIONURI && process.env.SYSTEM_TEAMPROJECT && process.env.BUILD_REPOSITORYNAME && process.env.BUILD_SOURCEBRANCHNAME) { const currentBranchUrl = `${process.env.SYSTEM_COLLECTIONURI}${encodeURIComponent(process.env.SYSTEM_TEAMPROJECT)}/_git/${encodeURIComponent(process.env.BUILD_REPOSITORYNAME)}?version=GB${process.env.BUILD_SOURCEBRANCHNAME}`; return currentBranchUrl; } uxLog(this, c.yellow(`[Azure DevOps] You need the following variables to be defined in azure devops pipeline step: ${this.getPipelineVariablesConfig()} `)); return null; } // Azure does not supports mermaid in PR markdown async supportsMermaidInPrMarkdown() { return false; } // Find pull request info async getPullRequestInfo() { // Case when PR is found in the context // Get CI variables const repositoryId = process.env.BUILD_REPOSITORY_ID || null; const pullRequestIdStr = process.env.SYSTEM_PULLREQUEST_PULLREQUESTID || null; const azureGitApi = await this.azureApi.getGitApi(); const currentGitBranch = await getCurrentGitBranch(); if (pullRequestIdStr !== null && !(pullRequestIdStr || "").includes("SYSTEM_PULLREQUEST_PULLREQUESTID") && !(pullRequestIdStr || "").includes("$(")) { const pullRequestId = Number(pullRequestIdStr); const pullRequest = await azureGitApi.getPullRequestById(pullRequestId); if (pullRequest && pullRequest.targetRefName) { // Add references to work items in PR result const pullRequestWorkItemRefs = await azureGitApi.getPullRequestWorkItemRefs(repositoryId || "", pullRequestId); if (!pullRequest.workItemRefs) { pullRequest.workItemRefs = pullRequestWorkItemRefs; } return this.completePullRequestInfo(pullRequest); } else { uxLog(this, c.yellow(`[Azure Integration] Warning: incomplete PR found (id: ${pullRequestIdStr})`)); uxLog(this, c.grey(JSON.stringify(pullRequest || {}))); } } // Case when we find PR from a commit const sha = await git().revparse(["HEAD"]); const latestPullRequestsOnBranch = await azureGitApi.getPullRequests(repositoryId || "", { targetRefName: `refs/heads/${currentGitBranch}`, status: PullRequestStatus.Completed, }); const latestMergedPullRequestOnBranch = latestPullRequestsOnBranch.filter((pr) => pr.mergeStatus === PullRequestAsyncStatus.Succeeded && pr.lastMergeCommit?.commitId === sha); if (latestMergedPullRequestOnBranch.length > 0) { const pullRequest = latestMergedPullRequestOnBranch[0]; // Add references to work items in PR result const pullRequestWorkItemRefs = await azureGitApi.getPullRequestWorkItemRefs(repositoryId || "", pullRequest.pullRequestId || 0); if (!pullRequest.workItemRefs) { pullRequest.workItemRefs = pullRequestWorkItemRefs; } return this.completePullRequestInfo(latestMergedPullRequestOnBranch[0]); } uxLog(this, c.grey(`[Azure Integration] Unable to find related Pull Request Info`)); return null; } async listPullRequests(filters = {}, options = { formatted: false }) { // Get Azure Git API const azureGitApi = await this.azureApi.getGitApi(); const repositoryId = process.env.BUILD_REPOSITORY_ID || null; if (repositoryId == null) { uxLog(this, c.yellow("[Azure Integration] Unable to find BUILD_REPOSITORY_ID")); return []; } const teamProject = process.env.SYSTEM_TEAMPROJECT || null; if (teamProject == null) { uxLog(this, c.yellow("[Azure Integration] Unable to find SYSTEM_TEAMPROJECT")); return []; } // Build search criteria const queryConstraint = {}; if (filters.pullRequestStatus) { const azurePrStatusValue = filters.pullRequestStatus === "open" ? PullRequestStatus.Active : filters.pullRequestStatus === "abandoned" ? PullRequestStatus.Abandoned : filters.pullRequestStatus === "merged" ? PullRequestStatus.Completed : null; if (azurePrStatusValue == null) { throw new SfError(`[Azure Integration] No matching status for ${filters.pullRequestStatus} in ${JSON.stringify(PullRequestStatus)}`); } queryConstraint.status = azurePrStatusValue; } else { queryConstraint.status = PullRequestStatus.All; } if (filters.targetBranch) { queryConstraint.targetRefName = `refs/heads/${filters.targetBranch}`; } if (filters.minDate) { queryConstraint.minTime = filters.minDate; } // Process request uxLog(this, c.cyan("Calling Azure API to list Pull Requests...")); uxLog(this, c.grey(`Constraint:\n${JSON.stringify(queryConstraint, null, 2)}`)); // List pull requests const pullRequests = await azureGitApi.getPullRequests(repositoryId, queryConstraint, teamProject); // Complete results with PR comments const pullRequestsWithComments = []; for (const pullRequest of pullRequests) { const pr = Object.assign({}, pullRequest); uxLog(this, c.grey(`Getting threads for PR ${pullRequest.pullRequestId}...`)); const existingThreads = await azureGitApi.getThreads(pullRequest.repository?.id || "", pullRequest.pullRequestId || 0, teamProject); pr.threads = existingThreads.filter(thread => !thread.isDeleted); pullRequestsWithComments.push(pr); } // Format if requested if (options.formatted) { uxLog(this, c.cyan(`Formatting ${pullRequestsWithComments.length} results...`)); const pullRequestsFormatted = pullRequestsWithComments.map(pr => { const prFormatted = {}; // Find sfdx-hardis deployment simulation status comment and extract tickets part let tickets = ""; for (const thread of pr.threads || []) { for (const comment of thread?.comments || []) { if ((comment?.content || "").includes(`<!-- sfdx-hardis deployment-id `)) { const ticketsSplit = comment.content.split("## Tickets"); if (ticketsSplit.length === 2) { tickets = ticketsSplit[1].split("## Commits summary")[0].trim(); } break; } if (tickets !== "") { break; } } } prFormatted.pullRequestId = pr.pullRequestId; prFormatted.targetRefName = (pr.targetRefName || "").replace("refs/heads/", ""); prFormatted.sourceRefName = (pr.sourceRefName || "").replace("refs/heads/", ""); prFormatted.status = PullRequestStatus[pr.status || 0]; prFormatted.mergeStatus = PullRequestAsyncStatus[pr.mergeStatus || 0]; prFormatted.title = pr.title; prFormatted.description = pr.description; prFormatted.tickets = tickets; prFormatted.closedBy = pr.closedBy?.uniqueName || pr.closedBy?.displayName; prFormatted.closedDate = pr.closedDate; prFormatted.createdBy = pr.createdBy?.uniqueName || pr.createdBy?.displayName; prFormatted.creationDate = pr.creationDate; prFormatted.reviewers = (pr.reviewers || []).map(reviewer => reviewer.uniqueName || reviewer.displayName).join(","); return prFormatted; }); return pullRequestsFormatted; } return pullRequestsWithComments; } async getBranchDeploymentCheckId(gitBranch) { let deploymentCheckId = null; // Get Azure Git API const azureGitApi = await this.azureApi.getGitApi(); const repositoryId = process.env.BUILD_REPOSITORY_ID || null; if (repositoryId == null) { uxLog(this, c.yellow("BUILD_REPOSITORY_ID must be defined")); return null; } const latestPullRequestsOnBranch = await azureGitApi.getPullRequests(repositoryId, { targetRefName: `refs/heads/${gitBranch}`, status: PullRequestStatus.Completed, }); const latestMergedPullRequestOnBranch = latestPullRequestsOnBranch.filter((pr) => pr.mergeStatus === PullRequestAsyncStatus.Succeeded); if (latestMergedPullRequestOnBranch.length > 0) { const latestPullRequest = latestMergedPullRequestOnBranch[0]; const latestPullRequestId = latestPullRequest.pullRequestId; deploymentCheckId = await this.getDeploymentIdFromPullRequest(azureGitApi, repositoryId, latestPullRequestId || 0, deploymentCheckId, latestPullRequest); } return deploymentCheckId; } async getPullRequestDeploymentCheckId() { const pullRequestInfo = await this.getPullRequestInfo(); if (pullRequestInfo) { const azureGitApi = await this.azureApi.getGitApi(); const repositoryId = process.env.BUILD_REPOSITORY_ID || null; if (repositoryId == null) { uxLog(this, c.yellow("BUILD_REPOSITORY_ID must be defined")); return null; } return await this.getDeploymentIdFromPullRequest(azureGitApi, repositoryId, pullRequestInfo.idNumber || 0, null, pullRequestInfo); } return null; } async getDeploymentIdFromPullRequest(azureGitApi, repositoryId, latestPullRequestId, deploymentCheckId, latestPullRequest) { const existingThreads = await azureGitApi.getThreads(repositoryId, latestPullRequestId); for (const existingThread of existingThreads) { if (existingThread.isDeleted) { continue; } for (const comment of existingThread?.comments || []) { if ((comment?.content || "").includes(`<!-- sfdx-hardis deployment-id `)) { const matches = /<!-- sfdx-hardis deployment-id (.*) -->/gm.exec(comment.content); if (matches) { deploymentCheckId = matches[1]; uxLog(this, c.gray(`Found deployment id ${deploymentCheckId} on PR #${latestPullRequestId} ${latestPullRequest.title}`)); break; } } } if (deploymentCheckId) { break; } } return deploymentCheckId; } // Posts a note on the merge request async postPullRequestMessage(prMessage) { // Get CI variables const repositoryId = process.env.BUILD_REPOSITORY_ID || null; const buildId = process.env.BUILD_BUILD_ID || null; const jobId = process.env.SYSTEM_JOB_ID || null; const pullRequestIdStr = process.env.SYSTEM_PULLREQUEST_PULLREQUESTID || null; if (repositoryId == null || pullRequestIdStr == null) { uxLog(this, c.grey("[Azure integration] No project and pull request, so no note thread...")); uxLog(this, c.yellow(`Following variables should be defined when available: ${this.getPipelineVariablesConfig()} `)); return { posted: false, providerResult: { info: "No related pull request" } }; } const pullRequestId = Number(pullRequestIdStr); const azureJobName = process.env.SYSTEM_JOB_DISPLAY_NAME; const SYSTEM_COLLECTIONURI = (process.env.SYSTEM_COLLECTIONURI || "").replace(/ /g, "%20"); const SYSTEM_TEAMPROJECT = (process.env.SYSTEM_TEAMPROJECT || "").replace(/ /g, "%20"); const azureBuildUri = `${SYSTEM_COLLECTIONURI}${encodeURIComponent(SYSTEM_TEAMPROJECT)}/_build/results?buildId=${buildId}&view=logs&j=${jobId}`; // Build thread message const messageKey = prMessage.messageKey + "-" + azureJobName + "-" + pullRequestId; let messageBody = `**${prMessage.title || ""}** ${prMessage.message} <br/> _Powered by [sfdx-hardis](${CONSTANTS.DOC_URL_ROOT}) from job [${azureJobName}](${azureBuildUri})_ <!-- sfdx-hardis message-key ${messageKey} --> `; // Add deployment id if present if (globalThis.pullRequestDeploymentId) { messageBody += `\n<!-- sfdx-hardis deployment-id ${globalThis.pullRequestDeploymentId} -->`; } // Upload attached images if necessary messageBody = await this.uploadAndReplaceImageReferences(messageBody, prMessage.sourceFile || ""); // Get Azure Git API const azureGitApi = await this.azureApi.getGitApi(); // Check for existing threads from a previous run uxLog(this, c.grey("[Azure integration] Listing Threads of Pull Request...")); const existingThreads = await azureGitApi.getThreads(repositoryId, pullRequestId); let existingThreadId = null; let existingThreadComment = null; let existingThreadCommentId = null; for (const existingThread of existingThreads) { if (existingThread.isDeleted) { continue; } for (const comment of existingThread?.comments || []) { if ((comment?.content || "").includes(`<!-- sfdx-hardis message-key ${messageKey} -->`)) { existingThreadComment = existingThread; existingThreadCommentId = (existingThread.comments || [])[0].id; existingThreadId = existingThread.id || null; break; } } if (existingThreadId) { break; } } // Create or update MR note if (existingThreadId) { // Delete previous comment uxLog(this, c.grey("[Azure integration] Deleting previous comment and closing previous thread...")); await azureGitApi.deleteComment(repositoryId, pullRequestId, existingThreadId, existingThreadCommentId || 0); existingThreadComment = await azureGitApi.getPullRequestThread(repositoryId, pullRequestId, existingThreadId); // Update existing thread existingThreadComment = { id: existingThreadComment.id, status: CommentThreadStatus.Closed, }; await azureGitApi.updateThread(existingThreadComment, repositoryId, pullRequestId, existingThreadId); } // Create new thread uxLog(this, c.grey("[Azure integration] Adding Pull Request Thread on Azure...")); const newThreadComment = { comments: [{ content: messageBody }], status: this.pullRequestStatusToAzureThreadStatus(prMessage), }; const azureEditThreadResult = await azureGitApi.createThread(newThreadComment, repositoryId, pullRequestId); const prResult = { posted: (azureEditThreadResult.id || -1) > 0, providerResult: azureEditThreadResult, }; uxLog(this, c.grey(`[Azure integration] Posted Pull Request Thread ${azureEditThreadResult.id}`)); return prResult; } // Convert sfdx-hardis PR status to Azure Thread status value pullRequestStatusToAzureThreadStatus(prMessage) { return prMessage.status === "valid" ? CommentThreadStatus.Fixed : prMessage.status === "invalid" ? CommentThreadStatus.Active : CommentThreadStatus.Unknown; } completePullRequestInfo(prData) { const prInfo = { idNumber: prData.pullRequestId || 0, idStr: String(prData.pullRequestId || 0), sourceBranch: (prData.sourceRefName || "").replace("refs/heads/", ""), targetBranch: (prData.targetRefName || "").replace("refs/heads/", ""), title: prData.title || "", description: prData.description || "", webUrl: `${process.env.SYSTEM_COLLECTIONURI}${encodeURIComponent(process.env.SYSTEM_TEAMPROJECT || "")}/_git/${encodeURIComponent(process.env.BUILD_REPOSITORYNAME || "")}/pullrequest/${prData.pullRequestId}`, authorName: prData?.createdBy?.displayName || "", providerInfo: prData, customBehaviors: {} }; return this.completeWithCustomBehaviors(prInfo); } getPipelineVariablesConfig() { return ` SFDX_DEPLOY_WAIT_MINUTES: 150 CI_COMMIT_REF_NAME: $(BRANCH_NAME) CONFIG_BRANCH: $(BRANCH_NAME) ORG_ALIAS: $(BRANCH_NAME) SLACK_TOKEN: $(SLACK_TOKEN) SLACK_CHANNEL_ID: $(SLACK_CHANNEL_ID) NOTIF_EMAIL_ADDRESS: $(NOTIF_EMAIL_ADDRESS) CI: "true" SYSTEM_ACCESSTOKEN: $(System.AccessToken) CI_SFDX_HARDIS_AZURE_TOKEN: $(System.AccessToken) SYSTEM_COLLECTIONURI: $(System.CollectionUri) SYSTEM_TEAMPROJECT: $(System.TeamProject) SYSTEM_JOB_DISPLAY_NAME: $(System.JobDisplayName) SYSTEM_JOB_ID: $(System.JobId) SYSTEM_PULLREQUEST_PULLREQUESTID: $(System.PullRequest.PullRequestId) BUILD_REPOSITORY_ID: $(Build.Repository.ID) BUILD_REPOSITORYNAME: $(Build.Repository.Name) BUILD_SOURCEBRANCHNAME: $(Build.SourceBranchName) BUILD_BUILD_ID: $(Build.BuildId)`; } // Do not make crash the whole process in case there is an issue with integration async tryPostPullRequestMessage(prMessage) { let prResult = null; try { prResult = await this.postPullRequestMessage(prMessage); } catch (e) { uxLog(this, c.yellow(`[GitProvider] Error while trying to post pull request message.\n${e.message}\n${e.stack}`)); prResult = { posted: false, providerResult: { error: e } }; } return prResult; } static parseAzureRepoUrl(remoteUrl) { let collectionUri; let repositoryId; let teamProject; if (remoteUrl.startsWith("https://")) { // Handle HTTPS URLs with or without username const httpsRegex = /https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/; const match = remoteUrl.match(httpsRegex); if (match) { const organization = match[1]; teamProject = decodeURIComponent(match[2]); // Decode URL-encoded project name repositoryId = decodeURIComponent(match[3]); // Decode URL-encoded repository name collectionUri = `https://dev.azure.com/${organization}/`; return { collectionUri, teamProject, repositoryId }; } } else if (remoteUrl.startsWith("git@")) { /* jscpd:ignore-start */ // Handle SSH URLs const sshRegex = /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)/; const match = remoteUrl.match(sshRegex); if (match) { const organization = match[1]; teamProject = decodeURIComponent(match[2]); // Decode URL-encoded project name repositoryId = decodeURIComponent(match[3]); // Decode URL-encoded repository name collectionUri = `https://dev.azure.com/${organization}/`; return { collectionUri, teamProject, repositoryId }; } /* jscpd:ignore-end */ } // Return null if the URL doesn't match expected patterns return null; } async uploadImage(localImagePath) { try { // Upload the image to Azure DevOps const imageName = path.basename(localImagePath); const imageContent = fs.createReadStream(localImagePath); const witApi = await this.azureApi.getWorkItemTrackingApi(); const attachment = await witApi.createAttachment(null, // Custom headers (usually null) imageContent, // File content imageName, // File name "simple", process.env.SYSTEM_TEAMPROJECT); if (attachment && attachment.url) { uxLog(this, c.grey(`[Azure Integration] Image uploaded for comment: ${attachment.url}`)); // Link attachment to work item const techWorkItemId = await this.findCreateAttachmentsWorkItemId(); if (techWorkItemId) { await witApi.updateWorkItem([], [ { op: "add", path: "/relations/-", value: { rel: "AttachedFile", url: attachment.url, attributes: { comment: "Uploaded Flow Diff image, generated by sfdx-hardis" } } } ], techWorkItemId, process.env.SYSTEM_TEAMPROJECT); uxLog(this, c.grey(`[Azure Integration] Attachment linked to work item ${techWorkItemId}`)); } return attachment.url; } else { uxLog(this, c.yellow(`[Azure Integration] Image uploaded but unable to get URL from response\n${JSON.stringify(attachment, null, 2)}`)); } } catch (e) { uxLog(this, c.yellow(`[Azure Integration] Error while uploading image ${localImagePath}\n${e.message}`)); } return null; } async findCreateAttachmentsWorkItemId() { if (this.attachmentsWorkItemId) { return this.attachmentsWorkItemId; } const workItemId = process.env.AZURE_ATTACHMENTS_WORK_ITEM_ID; if (workItemId) { this.attachmentsWorkItemId = Number(workItemId); return this.attachmentsWorkItemId; } // Try to find the work item const witApi = await this.azureApi.getWorkItemTrackingApi(); const wiql = { query: ` SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.Title] = '${this.attachmentsWorkItemTitle}' AND [System.TeamProject] = '${process.env.SYSTEM_TEAMPROJECT}' ` }; const queryResult = await witApi.queryByWiql(wiql); const workItemIds = (queryResult.workItems || []).map(item => item.id); if (workItemIds.length > 0) { this.attachmentsWorkItemId = Number(workItemIds[0]); return this.attachmentsWorkItemId; } uxLog(this, c.red(`[Azure Integration] You need to create a technical work item exactly named '${this.attachmentsWorkItemTitle}', then set its identifier in variable AZURE_ATTACHMENTS_WORK_ITEM_ID`)); return null; } } //# sourceMappingURL=azureDevops.js.map