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

366 lines (364 loc) • 19.3 kB
import { GitProviderRoot } from './gitProviderRoot.js'; import c from 'chalk'; import fs from "fs-extra"; import FormData from 'form-data'; import * as path from "path"; import { git, uxLog } from '../utils/index.js'; import bbPkg from 'bitbucket'; import { CONSTANTS } from '../../config/index.js'; const { Bitbucket } = bbPkg; export class BitbucketProvider extends GitProviderRoot { bitbucket; serverUrl = 'https://bitbucket.org'; token; constructor() { super(); this.token = process.env.CI_SFDX_HARDIS_BITBUCKET_TOKEN || ''; const clientOptions = { auth: { token: this.token } }; this.bitbucket = new Bitbucket(clientOptions); } getLabel() { return 'sfdx-hardis Bitbucket connector'; } async getCurrentJobUrl() { if (process.env.PIPELINE_JOB_URL) { return process.env.PIPELINE_JOB_URL; } if (process.env.BITBUCKET_WORKSPACE && process.env.BITBUCKET_REPO_SLUG && process.env.BITBUCKET_BUILD_NUMBER) { const jobUrl = `${this.serverUrl}/${process.env.BITBUCKET_WORKSPACE}/${process.env.BITBUCKET_REPO_SLUG}/pipelines/results/${process.env.BITBUCKET_BUILD_NUMBER}`; return jobUrl; } uxLog("warning", this, c.yellow(`[Bitbucket Integration] You need the following variables to be accessible to sfdx-hardis to build current job url: - BITBUCKET_WORKSPACE - BITBUCKET_REPO_SLUG - BITBUCKET_BUILD_NUMBER`)); return null; } async getCurrentBranchUrl() { if (process.env.BITBUCKET_WORKSPACE && process.env.BITBUCKET_REPO_SLUG && process.env.BITBUCKET_BRANCH) { const currentBranchUrl = `${this.serverUrl}/${process.env.BITBUCKET_WORKSPACE}/${process.env.BITBUCKET_REPO_SLUG}/branch/${process.env.BITBUCKET_BRANCH}`; return currentBranchUrl; } uxLog("warning", this, c.yellow(`[Bitbucket Integration] You need the following variables to be accessible to sfdx-hardis to build current job url: - BITBUCKET_WORKSPACE - BITBUCKET_REPO_SLUG - BITBUCKET_BRANCH`)); return null; } // Bitbucket does not supports mermaid in PR markdown async supportsMermaidInPrMarkdown() { return false; } // Find pull request info async getPullRequestInfo() { const pullRequestIdStr = process.env.BITBUCKET_PR_ID || null; const repoSlug = process.env.BITBUCKET_REPO_SLUG || null; const workspace = process.env.BITBUCKET_WORKSPACE || null; // Case when PR is found in the context if (pullRequestIdStr !== null) { const pullRequestId = Number(pullRequestIdStr); const pullRequest = await this.bitbucket.repositories.getPullRequest({ pull_request_id: pullRequestId, repo_slug: repoSlug || '', workspace: workspace || '', }); if (pullRequest?.data.destination) { // Add cross git provider properties used by sfdx-hardis return this.completePullRequestInfo(pullRequest.data); } else { uxLog("warning", this, c.yellow(`[Bitbucket Integration] Warning: incomplete PR found (id: ${pullRequestIdStr})`)); uxLog("log", this, c.grey(JSON.stringify(pullRequest || {}))); } } // Case when we find PR from a commit const sha = await git().revparse(['HEAD']); try { // Note: listPullrequestsForCommit API can be unreliable - it requires the "Pull Request Commit Links" app to be installed // See: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-commit-commit-pullrequests-get const latestPullRequestsOnBranch = await this.bitbucket.repositories.listPullrequestsForCommit({ workspace: workspace || '', repo_slug: repoSlug || '', commit: sha, }); const latestMergedPullRequestOnBranch = latestPullRequestsOnBranch?.data?.values?.filter((pr) => pr.state === 'MERGED' && pr.merge_commit?.hash === sha); if (latestMergedPullRequestOnBranch?.length && latestMergedPullRequestOnBranch?.length > 0) { const pullRequest = latestMergedPullRequestOnBranch[0]; // Add cross git provider properties used by sfdx-hardis return this.completePullRequestInfo(pullRequest); } } catch (e) { uxLog("warning", this, c.yellow(`[Bitbucket Integration] Unable to retrieve pull requests for commit ${sha}: ${e.message}`)); uxLog("log", this, c.grey(`[Bitbucket Integration] Note: This API requires the "Pull Request Commit Links" Bitbucket app to be installed on the repository.`)); } uxLog("log", this, c.grey(`[Bitbucket Integration] Unable to find related Pull Request Info`)); return null; } async getBranchDeploymentCheckId(gitBranch) { let deploymentCheckId = null; const repoSlug = process.env.BITBUCKET_REPO_SLUG || null; const workspace = process.env.BITBUCKET_WORKSPACE || null; const latestMergedPullRequestsOnBranch = await this.bitbucket.repositories.listPullRequests({ repo_slug: repoSlug || '', workspace: workspace || '', state: 'MERGED', q: `destination.branch.name = "${gitBranch}"`, sort: '-updated_on', }); if (latestMergedPullRequestsOnBranch?.data?.values?.length && latestMergedPullRequestsOnBranch?.data?.values?.length > 0) { const latestPullRequest = latestMergedPullRequestsOnBranch?.data?.values[0]; const latestPullRequestId = latestPullRequest.id; deploymentCheckId = await this.getDeploymentIdFromPullRequest(latestPullRequestId || 0, repoSlug || '', workspace || '', deploymentCheckId, this.completePullRequestInfo(latestPullRequest)); } return deploymentCheckId; } async getPullRequestDeploymentCheckId() { const pullRequestInfo = await this.getPullRequestInfo(); if (pullRequestInfo) { const repoSlug = process.env.BITBUCKET_REPO_SLUG || null; const workspace = process.env.BITBUCKET_WORKSPACE || null; return await this.getDeploymentIdFromPullRequest(pullRequestInfo.idNumber || 0, repoSlug || '', workspace || '', null, pullRequestInfo); } return null; } async getDeploymentIdFromPullRequest(latestPullRequestId, repoSlug, workspace, deploymentCheckId, latestPullRequest) { const comments = await this.bitbucket.repositories.listPullRequestComments({ pull_request_id: latestPullRequestId, repo_slug: repoSlug, workspace: workspace, }); for (const comment of comments?.data?.values || []) { if ((comment?.content?.raw || '').includes(`<!-- sfdx-hardis deployment-id `)) { const matches = /<!-- sfdx-hardis deployment-id (.*) -->/gm.exec(comment?.content?.raw || ''); if (matches) { deploymentCheckId = matches[1]; uxLog("log", this, c.grey(`[Bitbucket Integration] Found deployment id ${deploymentCheckId} on PR #${latestPullRequestId} ${latestPullRequest.title}`)); break; } } } return deploymentCheckId; } async listPullRequestsInBranchSinceLastMerge(currentBranchName, targetBranchName, childBranchesNames) { if (!this.bitbucket || !process.env.BITBUCKET_WORKSPACE || !process.env.BITBUCKET_REPO_SLUG) { return []; } try { const workspace = process.env.BITBUCKET_WORKSPACE; const repoSlug = process.env.BITBUCKET_REPO_SLUG; // Step 1: Find the last merged PR from currentBranch to targetBranch const lastMergeQuery = `source.branch.name = "${currentBranchName}" AND destination.branch.name = "${targetBranchName}" AND state = "MERGED"`; uxLog("log", this, c.grey(`[Bitbucket Integration] Finding last merged PR with query: ${lastMergeQuery}`)); const lastMergeResponse = await this.bitbucket.pullrequests.list({ workspace, repo_slug: repoSlug, q: lastMergeQuery, sort: '-updated_on', }); const lastMergePRs = lastMergeResponse && lastMergeResponse.data && lastMergeResponse.data.values ? lastMergeResponse.data.values : []; const lastMergeToTarget = lastMergePRs.length > 0 ? lastMergePRs[0] : null; if (lastMergeToTarget) { uxLog("log", this, c.grey(`[Bitbucket Integration] Last merged PR: #${lastMergeToTarget.id} - ${lastMergeToTarget.title}`)); } else { uxLog("log", this, c.grey(`[Bitbucket Integration] No previous merge found from ${currentBranchName} to ${targetBranchName}`)); } // Step 2: Get commits between branches const excludeRef = lastMergeToTarget ? lastMergeToTarget.merge_commit?.hash : targetBranchName; uxLog("log", this, c.grey(`[Bitbucket Integration] Getting commits: include=${currentBranchName}, exclude=${excludeRef}`)); const commitsResponse = await this.bitbucket.commits.list({ workspace, repo_slug: repoSlug, include: currentBranchName, exclude: excludeRef, }); const commits = commitsResponse && commitsResponse.data && commitsResponse.data.values ? commitsResponse.data.values : []; uxLog("log", this, c.grey(`[Bitbucket Integration] Found ${commits.length} commits between branches`)); if (commits.length === 0) { return []; } const commitHashes = new Set(commits.map((c) => c.hash)); // Step 3: Get all merged PRs targeting currentBranch and child branches (parallelized) const allBranches = [currentBranchName, ...childBranchesNames]; uxLog("log", this, c.grey(`[Bitbucket Integration] Fetching merged PRs for branches: ${allBranches.join(', ')}`)); const prPromises = allBranches.map(async (branchName) => { try { const branchQuery = `destination.branch.name = "${branchName}" AND state = "MERGED"`; const response = await this.bitbucket.pullrequests.list({ workspace, repo_slug: repoSlug, q: branchQuery, }); const values = response && response.data && response.data.values ? response.data.values : []; uxLog("log", this, c.grey(`[Bitbucket Integration] Found ${values.length} merged PRs for branch ${branchName}`)); return values; } catch (err) { uxLog("warning", this, c.yellow(`[Bitbucket Integration] Error fetching merged PRs for branch ${branchName}: ${String(err)}`)); return []; } }); const prResults = await Promise.all(prPromises); const allMergedPRs = prResults.flat(); uxLog("log", this, c.grey(`[Bitbucket Integration] Total merged PRs fetched: ${allMergedPRs.length}`)); // Step 4: Filter PRs whose merge commit is in our commit list const relevantPRs = allMergedPRs.filter((pr) => { const mergeCommitHash = pr.merge_commit?.hash; return mergeCommitHash && commitHashes.has(mergeCommitHash); }); uxLog("log", this, c.grey(`[Bitbucket Integration] Relevant PRs (matching commits): ${relevantPRs.length}`)); // Step 5: Remove duplicates const uniquePRsMap = new Map(); for (const pr of relevantPRs) { if (!uniquePRsMap.has(pr.id)) { uniquePRsMap.set(pr.id, pr); } } const uniquePRs = Array.from(uniquePRsMap.values()); uxLog("log", this, c.grey(`[Bitbucket Integration] Unique PRs after deduplication: ${uniquePRs.length}`)); // Step 6: Convert to CommonPullRequestInfo return uniquePRs.map((pr) => this.completePullRequestInfo(pr)); } catch (err) { uxLog("warning", this, c.yellow(`Error in listPullRequestsInBranchSinceLastMerge: ${String(err)}`)); return []; } } async postPullRequestMessage(prMessage) { const prInfo = await this.getPullRequestInfo(); const pullRequestIdStr = process.env.BITBUCKET_PR_ID || prInfo?.idStr || null; const repoSlug = process.env.BITBUCKET_REPO_SLUG || null; const workspace = process.env.BITBUCKET_WORKSPACE || null; if (repoSlug == null || pullRequestIdStr == null) { uxLog("log", this, c.grey('[Bitbucket integration] No repo and pull request, so no note posted...')); return { posted: false, providerResult: { info: 'No related pull request' } }; } const pullRequestId = Number(pullRequestIdStr); const bitbucketBuildNumber = process.env.BITBUCKET_BUILD_NUMBER || null; const bitbucketJobUrl = await this.getCurrentJobUrl(); const messageKey = `${prMessage.messageKey}-${pullRequestId}`; let messageBody = `## ${prMessage.title || ''} ${prMessage.message} \n_Powered by [sfdx-hardis](${CONSTANTS.DOC_URL_ROOT}) from job [${bitbucketBuildNumber}](${bitbucketJobUrl})_ \n<!-- sfdx-hardis message-key ${messageKey} --> `; // Add deployment id if present if (globalThis.pullRequestDeploymentId) { messageBody += `\n<!-- sfdx-hardis deployment-id ${globalThis.pullRequestDeploymentId} -->`; } messageBody = await this.uploadAndReplaceImageReferences(messageBody, prMessage.sourceFile || ""); const commentBody = { content: { raw: messageBody, }, }; // Check for existing comment from a previous run uxLog("log", this, c.grey('[Bitbucket integration] Listing comments of Pull Request...')); const existingComments = await this.bitbucket.repositories.listPullRequestComments({ pull_request_id: pullRequestId, repo_slug: repoSlug, workspace: workspace || '', }); let existingCommentId = null; for (const existingComment of existingComments?.data?.values || []) { if (existingComment?.content?.raw && existingComment?.content.raw?.includes(`<!-- sfdx-hardis message-key ${messageKey} -->`)) { existingCommentId = existingComment.id || null; } } // Create or update MR comment if (existingCommentId) { // Update existing comment uxLog("log", this, c.grey('[Bitbucket integration] Updating Pull Request Comment on Bitbucket...')); const pullRequestComment = await this.bitbucket.repositories.updatePullRequestComment({ workspace: workspace || '', repo_slug: repoSlug, pull_request_id: pullRequestId, comment_id: existingCommentId, _body: commentBody, }); const prResult = { posted: (pullRequestComment?.data?.id || -1) > 0, providerResult: pullRequestComment, }; uxLog("log", this, c.grey(`[Bitbucket integration] Updated Pull Request comment ${existingCommentId}`)); return prResult; } else { // Create new comment if no existing comment was found uxLog("log", this, c.grey('[Bitbucket integration] Adding Pull Request Comment on Bitbucket...')); const pullRequestComment = await this.bitbucket.repositories.createPullRequestComment({ workspace: workspace || '', repo_slug: repoSlug, pull_request_id: pullRequestId, _body: commentBody, }); const prResult = { posted: (pullRequestComment?.data?.id || -1) > 0, providerResult: pullRequestComment, }; if (prResult.posted) { uxLog("log", this, c.grey(`[Bitbucket integration] Posted Pull Request comment on ${pullRequestId}`)); } else { uxLog("warning", this, c.yellow(`[Bitbucket integration] Unable to post Pull Request comment on ${pullRequestId}:\n${JSON.stringify(pullRequestComment, null, 2)}`)); } return prResult; } } completePullRequestInfo(prData) { const prInfo = { idNumber: prData?.id || 0, idStr: prData.id ? prData?.id?.toString() : '', sourceBranch: prData?.source?.branch?.name || '', targetBranch: prData?.destination?.branch?.name || '', title: prData?.rendered?.title?.raw || prData?.rendered?.title?.markup || prData?.rendered?.title?.html || '', description: prData?.rendered?.description?.raw || prData?.rendered?.description?.markup || prData?.rendered?.description?.html || '', webUrl: prData?.links?.html?.href || '', authorName: prData?.author?.display_name || '', providerInfo: prData, customBehaviors: {} }; return this.completeWithCustomBehaviors(prInfo); } // Upload the image to Bitbucket async uploadImage(localImagePath) { try { const imageName = path.basename(localImagePath); const filesForm = new FormData(); filesForm.append("files", fs.createReadStream(localImagePath)); const attachmentResponse = await this.bitbucket.repositories.createDownload({ workspace: process.env.BITBUCKET_WORKSPACE || "", repo_slug: process.env.BITBUCKET_REPO_SLUG || "", _body: filesForm, }); if (attachmentResponse) { const imageRef = `${this.serverUrl}/${process.env.BITBUCKET_WORKSPACE}/${process.env.BITBUCKET_REPO_SLUG}/downloads/${imageName}`; uxLog("log", this, c.grey(`[Bitbucket Integration] Image uploaded for comment: ${imageRef}`)); return imageRef; } else { uxLog("warning", this, c.yellow(`[Bitbucket Integration] Image uploaded but unable to get URL from response\n${JSON.stringify(attachmentResponse, null, 2)}`)); } } catch (e) { uxLog("warning", this, c.yellow(`[Bitbucket Integration] Error while uploading image in downloads section ${localImagePath}\n${e.message}`)); } return null; } } //# sourceMappingURL=bitbucket.js.map