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