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
747 lines (744 loc) • 40.1 kB
JavaScript
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, getEnvVar } 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("warning", 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("warning", this, c.yellow("[Azure Integration] An git origin must be set"));
return;
}
const parseUrlRes = this.parseAzureRepoUrl(repoUrl);
if (!parseUrlRes) {
uxLog("warning", 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("warning", 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("warning", 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",
description: "Enter your Azure DevOps Personal Access Token for API authentication (will not be stored permanently)",
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("warning", 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("warning", 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;
}
// Extract PR ID from commit message (fallback when SYSTEM_PULLREQUEST_PULLREQUESTID is null after merge)
async extractPullRequestIdFromCommitMessage() {
try {
const log = await git().log(['-1']); // Get the latest commit message
const commitMessage = log?.latest?.message || '';
if (!commitMessage) {
return null;
}
// Azure DevOps merge commit patterns:
// - "Merge pull request #123 from branch-name"
// - "Merged PR #123: Title"
// - "Merge PR #123"
// - "Merged pull request #123"
const prIdPatterns = [
/(?:Merge|Merged)\s+(?:pull\s+request|PR)\s+#(\d+)/i,
/(?:Merge|Merged)\s+PR\s+#(\d+)/i,
/pull\s+request\s+#(\d+)/i,
/PR\s+#(\d+)/i,
];
for (const pattern of prIdPatterns) {
const match = commitMessage.match(pattern);
if (match && match[1]) {
const prId = Number(match[1]);
if (!isNaN(prId) && prId > 0) {
uxLog("log", this, c.grey(`[Azure Integration] Extracted PR ID ${prId} from commit message`));
return prId;
}
}
}
}
catch (error) {
uxLog("log", this, c.grey(`[Azure Integration] Unable to extract PR ID from commit message: ${error.message}`));
}
return null;
}
// 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;
let pullRequestIdStr = process.env.SYSTEM_PULLREQUEST_PULLREQUESTID || null;
const azureGitApi = await this.azureApi.getGitApi();
const currentGitBranch = await getCurrentGitBranch();
// If SYSTEM_PULLREQUEST_PULLREQUESTID is null or invalid, try to extract from commit message
if (pullRequestIdStr === null) {
const extractedPrId = await this.extractPullRequestIdFromCommitMessage();
if (extractedPrId !== null) {
pullRequestIdStr = String(extractedPrId);
}
}
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("warning", this, c.yellow(`[Azure 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"]);
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("log", 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("warning", this, c.yellow("[Azure Integration] Unable to find BUILD_REPOSITORY_ID"));
return [];
}
const teamProject = process.env.SYSTEM_TEAMPROJECT || null;
if (teamProject == null) {
uxLog("warning", 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("action", this, c.cyan("Calling Azure API to list Pull Requests..."));
uxLog("log", 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("log", 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("action", 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("warning", 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("warning", 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("error", this, c.grey(`Found deployment id ${deploymentCheckId} on PR #${latestPullRequestId} ${latestPullRequest.title}`));
break;
}
}
}
if (deploymentCheckId) {
break;
}
}
return deploymentCheckId;
}
async listPullRequestsInBranchSinceLastMerge(currentBranchName, targetBranchName, childBranchesNames) {
if (!this.azureApi || !process.env.SYSTEM_TEAMPROJECT || !process.env.BUILD_REPOSITORY_ID) {
return [];
}
try {
const gitApi = await this.azureApi.getGitApi();
// Step 1: Find the last completed PR from currentBranch to targetBranch
const lastMergePRs = await gitApi.getPullRequests(process.env.BUILD_REPOSITORY_ID, {
sourceRefName: `refs/heads/${currentBranchName}`,
targetRefName: `refs/heads/${targetBranchName}`,
status: PullRequestStatus.Completed,
}, process.env.SYSTEM_TEAMPROJECT, undefined, undefined, 1);
uxLog("log", this, c.grey(`[Azure Integration][listPullRequestsInBranchSinceLastMerge] Last merge PR query: ${currentBranchName} -> ${targetBranchName}`));
const lastMergedPrToTarget = lastMergePRs && lastMergePRs.length > 0 ? lastMergePRs[0] : null;
// Step 2: Get commits since last merge
const commitsCriteria = {
compareVersion: {
version: currentBranchName,
versionType: 0, // GitVersionType.Branch
},
};
// If there was a previous merge, use the merge commit (from target branch) as the base comparison point
if (lastMergedPrToTarget?.lastMergeSourceCommit?.commitId) {
commitsCriteria.itemVersion = {
version: lastMergedPrToTarget?.lastMergeSourceCommit?.commitId,
versionType: 2, // GitVersionType.Commit
};
}
else {
// No previous merge, compare against target branch to get all commits
// Just list all commits in currentBranch
commitsCriteria.itemVersion = {
version: targetBranchName,
versionType: 0, // GitVersionType.Branch
};
}
const commits = await gitApi.getCommitsBatch(commitsCriteria, process.env.BUILD_REPOSITORY_ID, process.env.SYSTEM_TEAMPROJECT);
uxLog("log", this, c.grey(`[Azure Integration][listPullRequestsInBranchSinceLastMerge] Found ${commits?.length || 0} commits since last merge`));
if (!commits || commits.length === 0) {
return [];
}
// Create a Set of commit IDs for fast lookup
const commitIds = new Set(commits.map((c) => c.commitId).filter((id) => id));
// Step 3: Get all completed PRs targeting currentBranch and child branches (parallelized)
const allBranches = [currentBranchName, ...childBranchesNames];
const prPromises = allBranches.map(async (branchName) => {
try {
const prs = await gitApi.getPullRequests(process.env.BUILD_REPOSITORY_ID, {
targetRefName: `refs/heads/${branchName}`,
status: PullRequestStatus.Completed,
}, process.env.SYSTEM_TEAMPROJECT);
uxLog("log", this, c.grey(`[Azure Integration][listPullRequestsInBranchSinceLastMerge] Found ${prs?.length || 0} completed PRs for branch ${branchName}`));
return prs || [];
}
catch (err) {
uxLog("warning", this, c.yellow(`Error fetching completed PRs for branch ${branchName}: ${String(err)}`));
return [];
}
});
const prResults = await Promise.all(prPromises);
const allMergedPRs = prResults.flat();
// Step 4: Filter PRs whose merge commit is in our commit list
const relevantPRs = allMergedPRs.filter((pr) => {
// Check if the merge commit ID is in our commits
const mergeCommitId = pr.lastMergeCommit?.commitId;
if (mergeCommitId && commitIds.has(mergeCommitId)) {
return true;
}
// Also check the source commit (last commit from the PR branch before merge)
const sourceCommitId = pr.lastMergeSourceCommit?.commitId;
if (sourceCommitId && commitIds.has(sourceCommitId)) {
return true;
}
return false;
});
// Step 5: Remove duplicates
const uniquePRsMap = new Map();
for (const pr of relevantPRs) {
if (!uniquePRsMap.has(pr.pullRequestId)) {
uniquePRsMap.set(pr.pullRequestId, pr);
}
}
const uniquePRs = Array.from(uniquePRsMap.values());
uxLog("log", this, c.grey(`[Azure Integration][listPullRequestsInBranchSinceLastMerge] Returning ${uniquePRs.length} unique PRs`));
// 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 [];
}
}
// Posts a note on the merge request
async postPullRequestMessage(prMessage) {
// Get CI variables
const prInfo = await this.getPullRequestInfo();
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 = getEnvVar("SYSTEM_PULLREQUEST_PULLREQUESTID") || prInfo?.idStr || null;
if (repositoryId == null || pullRequestIdStr == null) {
uxLog("log", this, c.grey("[Azure integration] No project and pull request, so no note thread..."));
uxLog("warning", 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("log", this, c.grey(`[Azure integration] Listing Threads of Pull Request #${pullRequestId} ...`));
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("log", 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("log", 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("log", 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: $(SFDX_DEPLOY_WAIT_MINUTES)
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("warning", 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 modern dev.azure.com URLs with or without username
const devAzureRegex = /https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/;
const devAzureMatch = remoteUrl.match(devAzureRegex);
if (devAzureMatch) {
const organization = devAzureMatch[1];
teamProject = decodeURIComponent(devAzureMatch[2]); // Decode URL-encoded project name
repositoryId = decodeURIComponent(devAzureMatch[3]); // Decode URL-encoded repository name
collectionUri = `https://dev.azure.com/${organization}/`;
return { collectionUri, teamProject, repositoryId };
}
// Handle legacy visualstudio.com URLs
// Format: https://organization.visualstudio.com/ProjectName/_git/RepoName
const vsRegex = /https:\/\/(?:[^@]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/?]+)/;
const vsMatch = remoteUrl.match(vsRegex);
if (vsMatch) {
const organization = vsMatch[1];
teamProject = decodeURIComponent(vsMatch[2]); // Decode URL-encoded project name
repositoryId = decodeURIComponent(vsMatch[3]); // Decode URL-encoded repository name
collectionUri = `https://${organization}.visualstudio.com/`;
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("log", 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("log", this, c.grey(`[Azure Integration] Attachment linked to work item ${techWorkItemId}`));
}
return attachment.url;
}
else {
uxLog("warning", this, c.yellow(`[Azure Integration] Image uploaded but unable to get URL from response\n${JSON.stringify(attachment, null, 2)}`));
}
}
catch (e) {
uxLog("warning", 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]);
// Check the number of attached images: if too many, rename the work item with (full) then create a new one by cloning its parameters
const workItem = await witApi.getWorkItem(this.attachmentsWorkItemId, undefined, undefined, 1); // WorkItemExpand.Relations = 1
const attachedImages = (workItem.relations || []).filter(rel => rel.rel === "AttachedFile");
if (attachedImages.length >= 90) {
// Rename the work item
const newTitle = this.attachmentsWorkItemTitle + " (full)";
await witApi.updateWorkItem([], [
{
op: "replace",
path: "/fields/System.Title",
value: newTitle
}
], this.attachmentsWorkItemId, process.env.SYSTEM_TEAMPROJECT);
uxLog("log", this, c.grey(`[Azure Integration] Renamed work item ${this.attachmentsWorkItemId} to '${newTitle}'`));
// Create a new work item by cloning the old one's parameters
const newWorkItem = await witApi.createWorkItem([], [
{
op: "add",
path: "/fields/System.Title",
value: this.attachmentsWorkItemTitle
},
{
op: "add",
path: "/fields/System.WorkItemType",
value: workItem.fields?.["System.WorkItemType"] || "Task"
},
{
op: "add",
path: "/fields/System.Description",
value: "Technical work item used by sfdx-hardis to attach images for PR comments"
}
], process.env.SYSTEM_TEAMPROJECT, workItem.fields?.["System.WorkItemType"] || "Task");
if (newWorkItem && newWorkItem.id) {
this.attachmentsWorkItemId = newWorkItem.id;
uxLog("log", this, c.grey(`[Azure Integration] Created new technical work item ${this.attachmentsWorkItemId} (${this.attachmentsWorkItemTitle}) to store image attachments, previous one was full`));
}
}
else {
uxLog("log", this, c.grey(`[Azure Integration] Found existing technical work item ${this.attachmentsWorkItemId} (${this.attachmentsWorkItemTitle}) for storing image attachments`));
}
return this.attachmentsWorkItemId;
}
// No work item found, create a new one
uxLog("log", this, c.grey(`[Azure Integration] No technical work item found with title '${this.attachmentsWorkItemTitle}' to store image attachments, attempting to create one automatically...`));
try {
const newWorkItem = await witApi.createWorkItem([], [
{
op: "add",
path: "/fields/System.Title",
value: this.attachmentsWorkItemTitle
},
{
op: "add",
path: "/fields/System.WorkItemType",
value: "Task"
},
{
op: "add",
path: "/fields/System.Description",
value: "Technical work item used by sfdx-hardis to store image attachments for PR comments. This work item serves as a container for uploaded images and should not be deleted."
}
], process.env.SYSTEM_TEAMPROJECT, "Task");
if (newWorkItem && newWorkItem.id) {
this.attachmentsWorkItemId = newWorkItem.id;
uxLog("log", this, c.grey(`[Azure Integration] Successfully created technical work item ${this.attachmentsWorkItemId} (${this.attachmentsWorkItemTitle}) to store image attachments for PR comments`));
return this.attachmentsWorkItemId;
}
}
catch (e) {
uxLog("warning", this, c.yellow(`[Azure Integration] Failed to automatically create technical work item for storing image attachments: ${e.message}`));
uxLog("warning", this, c.yellow(`[Azure Integration] Please manually create a work item (type: Task) with the exact title '${this.attachmentsWorkItemTitle}' in project '${process.env.SYSTEM_TEAMPROJECT}', or set the AZURE_ATTACHMENTS_WORK_ITEM_ID environment variable with an existing work item ID.`));
uxLog("warning", this, c.yellow(`[Azure Integration] This work item is required as a container to store image attachments for Pull Request comments.`));
}
uxLog("error", this, c.yellow(`[Azure Integration] Unable to find or create technical work item for image attachments. Image uploads to PR comments will not work until this is resolved.`));
return null;
}
}
//# sourceMappingURL=azureDevops.js.map