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
JavaScript
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