diff --git a/.gitignore b/.gitignore index 7da9f5f1d..4fadb42fb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ *.iml #Project libs -/sonarqube-lib/ \ No newline at end of file +/sonarqube-lib/ + +#VSCode +.project \ No newline at end of file diff --git a/build.gradle b/build.gradle index 237b6ab0f..d5e0269d6 100644 --- a/build.gradle +++ b/build.gradle @@ -116,6 +116,11 @@ tasks.shadowJar.configure { classifier = null } +tasks.withType(JavaCompile) { + options.compilerArgs << '-Xlint:unchecked' + options.deprecation = true +} + assemble.dependsOn('shadowJar') pitest { diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProvider.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProvider.java index 667412c5b..736f832e1 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProvider.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProvider.java @@ -20,6 +20,7 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PostAnalysisIssueVisitor; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PullRequestPostAnalysisTask; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.AzureDevOpsServerPullRequestDecorator; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.bitbucket.BitbucketPullRequestDecorator; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.GithubPullRequestDecorator; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.DefaultLinkHeaderReader; @@ -41,7 +42,8 @@ public List getComponents() { return Arrays.asList(CommunityBranchLoaderDelegate.class, PullRequestPostAnalysisTask.class, PostAnalysisIssueVisitor.class, GithubPullRequestDecorator.class, GraphqlCheckRunProvider.class, DefaultLinkHeaderReader.class, RestApplicationAuthenticationProvider.class, - BitbucketPullRequestDecorator.class, GitlabServerPullRequestDecorator.class); + BitbucketPullRequestDecorator.class, GitlabServerPullRequestDecorator.class, + AzureDevOpsServerPullRequestDecorator.class); } } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetails.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetails.java index 934bc94e0..5f518bb8e 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetails.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetails.java @@ -65,6 +65,10 @@ public class AnalysisDetails { + public static final String SCANNERROPERTY_PULLREQUEST_BRANCH = "sonar.pullrequest.branch"; + public static final String SCANNERROPERTY_PULLREQUEST_BASE = "sonar.pullrequest.base"; + public static final String SCANNERROPERTY_PULLREQUEST_KEY = "sonar.pullrequest.key"; + private static final List CLOSED_ISSUE_STATUS = Arrays.asList(Issue.STATUS_CLOSED, Issue.STATUS_RESOLVED); private static final List COVERAGE_LEVELS = @@ -116,11 +120,27 @@ public String getDashboardUrl() { public String getIssueUrl(String issueKey) { return publicRootURL + "/project/issues?id=" + encode(project.getKey()) + "&pullRequest=" + branchDetails.getBranchName() + "&issues=" + issueKey + "&open=" + issueKey; } + + public Optional getPullRequestBase() { + return Optional.ofNullable(scannerContext.getProperties().get(SCANNERROPERTY_PULLREQUEST_BASE)); + } + + public Optional getPullRequestBranch() { + return Optional.ofNullable(scannerContext.getProperties().get(SCANNERROPERTY_PULLREQUEST_BRANCH)); + } + + public Optional getPullRequestKey() { + return Optional.ofNullable(scannerContext.getProperties().get(SCANNERROPERTY_PULLREQUEST_KEY)); + } public QualityGate.Status getQualityGateStatus() { return qualityGate.getStatus(); } + public String getRuleUrlWithRuleKey(String ruleKey) { + return publicRootURL + "/coding_rules?open=" + encode(ruleKey) + "&rule_key=" + encode(ruleKey); + } + public Optional getScannerProperty(String propertyName) { return Optional.ofNullable(scannerContext.getProperties().get(propertyName)); } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecorator.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecorator.java new file mode 100644 index 000000000..c596566f3 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecorator.java @@ -0,0 +1,385 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.DecorationResult; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PostAnalysisIssueVisitor; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PullRequestBuildStatusDecorator; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.AzurePullRequestDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.Comment; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.CommentThread; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.GitStatusContext; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.GitPullRequestStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.CommentThreadResponse; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.CommentThreadStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.mappers.GitStatusStateMapper; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.sonar.api.issue.Issue; +import org.sonar.api.platform.Server; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.protobuf.DbIssues; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class AzureDevOpsServerPullRequestDecorator implements PullRequestBuildStatusDecorator { + + // SCANNER PROPERTIES + public static final String PULLREQUEST_AZUREDEVOPS_API_VERSION = "sonar.pullrequest.vsts.apiVersion"; // sonar.pullrequest.vsts.apiVersion=5.1-preview.1 + public static final String PULLREQUEST_AZUREDEVOPS_INSTANCE_URL = "sonar.pullrequest.vsts.instanceUrl"; // sonar.pullrequest.vsts.instanceUrl=https://dev.azure.com/fabrikam/ + public static final String PULLREQUEST_AZUREDEVOPS_PROJECT_ID = "sonar.pullrequest.vsts.project"; // sonar.pullrequest.vsts.project=MyProject + public static final String PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME = "sonar.pullrequest.vsts.repository"; // sonar.pullrequest.vsts.repository=MyReposytory + + // AZURE DEVOPS ENVIRONMENT VARIABLES: https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml + public static final String AZUREDEVOPS_ENV_INSTANCE_URL = "System.TeamFoundationCollectionUri"; + public static final String AZUREDEVOPS_ENV_BASE_BRANCH = "System.PullRequest.TargetBranch"; + public static final String AZUREDEVOPS_ENV_BRANCH = "System.PullRequest.SourceBranch"; + public static final String AZUREDEVOPS_ENV_PULLREQUEST_ID = "System.PullRequest.PullRequestId"; + public static final String AZUREDEVOPS_ENV_TEAMPROJECT_ID = "System.TeamProjectId"; + public static final String AZUREDEVOPS_ENV_REPOSITORY_NAME = "Build.Repository.Name"; + + private static final Logger LOGGER = Loggers.get(AzureDevOpsServerPullRequestDecorator.class); + private static final String GENERAL_ERROR_MESSAGE = "An error was returned in the response from the Azure DevOps server. See the previous log messages for details"; + + private final Server server; + + public AzureDevOpsServerPullRequestDecorator(Server server) { + super(); + this.server = server; + } + + @Override + public DecorationResult decorateQualityGateStatus(AnalysisDetails analysisDetails, AlmSettingDto almSettingDto, ProjectAlmSettingDto projectAlmSettingDto) { + + LOGGER.info(String.format("starting to analyze with %s", analysisDetails.toString())); + + final String missingPropertyMessage = "Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties"; + + try { + String pullRequestId = analysisDetails.getBranchName(); + String azureUrl = analysisDetails.getScannerProperty(PULLREQUEST_AZUREDEVOPS_INSTANCE_URL) + .map(url -> url.endsWith("/") ? url : url + "/").orElseThrow( + () -> new IllegalStateException(String.format(missingPropertyMessage, PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))); + String baseBranch = analysisDetails.getPullRequestBase().orElseThrow( + () -> new IllegalStateException(String.format(missingPropertyMessage, analysisDetails.SCANNERROPERTY_PULLREQUEST_BASE))); + String branch = analysisDetails.getPullRequestBranch().orElseThrow( + () -> new IllegalStateException(String.format(missingPropertyMessage, analysisDetails.SCANNERROPERTY_PULLREQUEST_BRANCH))); + String azureRepositoryName = analysisDetails.getScannerProperty(PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME).orElseThrow( + () -> new IllegalStateException(String.format(missingPropertyMessage,PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))); + String azureProjectId = analysisDetails.getScannerProperty(PULLREQUEST_AZUREDEVOPS_PROJECT_ID).orElseThrow( + () -> new IllegalStateException(String.format(missingPropertyMessage, PULLREQUEST_AZUREDEVOPS_PROJECT_ID))); + String apiVersion = analysisDetails.getScannerProperty(PULLREQUEST_AZUREDEVOPS_API_VERSION).orElse("6.0-preview.1"); + + if (almSettingDto.getPersonalAccessToken() == null) { + throw new IllegalStateException("Could not decorate AzureDevOps pullRequest. Access token has not been set"); + } + + LOGGER.trace(String.format("azureUrl is: %s ", azureUrl)); + LOGGER.trace(String.format("baseBranch is: %s ", baseBranch)); + LOGGER.trace(String.format("branch is: %s ", branch)); + LOGGER.trace(String.format("pullRequestId is: %s ", pullRequestId)); + LOGGER.trace(String.format("azureProjectId is: %s ", azureProjectId)); + LOGGER.trace(String.format("azureRepositoryName is: %s ", azureRepositoryName)); + LOGGER.trace(String.format("apiVersion is: %s ", apiVersion)); + + AzurePullRequestDetails azurePullRequestDetails = new AzurePullRequestDetails(apiVersion, azureRepositoryName, azureProjectId, azureUrl, + baseBranch, branch, almSettingDto.getPersonalAccessToken(), pullRequestId); + + sendPost( + getStatusApiUrl(azurePullRequestDetails), + getGitPullRequestStatus(analysisDetails), + "Status set successfully", + azurePullRequestDetails.getAuthorizationHeader() + ); + + List openIssues = analysisDetails.getPostAnalysisIssueVisitor() + .getIssues(); + + LOGGER.trace(String.format("Analyze issue count: %s ", openIssues.size())); + + ArrayList azureCommentThreads = new ArrayList<>(Arrays.asList( + sendGet(getThreadApiUrl(azurePullRequestDetails), + CommentThreadResponse.class, azurePullRequestDetails.getAuthorizationHeader()).getValue())); + + LOGGER.trace(String.format("Azure commentThreads count: %s ", azureCommentThreads.size())); + azureCommentThreads.removeIf(x -> x.getThreadContext() == null || x.isDeleted()); + LOGGER.trace(String.format("Azure commentThreads AFTER REMOVE count: %s ", azureCommentThreads.size())); + + for (PostAnalysisIssueVisitor.ComponentIssue issue : openIssues) { + handleIssue(analysisDetails, azurePullRequestDetails, issue, azureCommentThreads); + } + return DecorationResult.builder().withPullRequestUrl(getPullRequestUrl(azurePullRequestDetails)).build(); + } catch (Exception ex) { + throw new IllegalStateException("Could not decorate Pull Request on AzureDevOps Server", ex); + } + } + + private void handleIssue(AnalysisDetails analysisDetails, AzurePullRequestDetails azurePullRequestDetails, + PostAnalysisIssueVisitor.ComponentIssue issue, ArrayList azureCommentThreads) { + String filePath = analysisDetails.getSCMPathForIssue(issue).orElse(null); + + if (filePath != null) { + try { + Integer line = issue.getIssue().getLine(); + filePath = filePath.endsWith("/") ? filePath : "/" + filePath; + LOGGER.trace(String.format("ISSUE: authorLogin: %s ", issue.getIssue().authorLogin())); + LOGGER.trace(String.format("ISSUE: key: %s ", issue.getIssue().key())); + LOGGER.trace(String.format("ISSUE: type: %s ", issue.getIssue().type().toString())); + LOGGER.trace(String.format("ISSUE: severity: %s ", issue.getIssue().severity())); + LOGGER.trace(String.format("ISSUE: componentKey: %s ", issue.getIssue().componentKey())); + LOGGER.trace(String.format("ISSUE: getLocations: %s ", Objects.requireNonNull(issue.getIssue().getLocations()).toString())); + LOGGER.trace(String.format("ISSUE: getRuleKey: %s ", issue.getIssue().getRuleKey())); + LOGGER.trace(String.format("COMPONENT: getDescription: %s ", issue.getComponent().getDescription())); + DbIssues.Locations locate = Objects.requireNonNull(issue.getIssue().getLocations()); + boolean threadExists = false; + for (CommentThread azureThread : azureCommentThreads) { + LOGGER.trace(String.format("azureFilePath: %s", azureThread.getThreadContext().getFilePath())); + LOGGER.trace(String.format("filePath: %s (%s)", filePath, azureThread.getThreadContext().getFilePath().equals(filePath))); + LOGGER.trace(String.format("azureLine: %d", azureThread.getThreadContext().getRightFileStart().getLine())); + LOGGER.trace(String.format("line: %d (%s)", line, azureThread.getThreadContext().getRightFileStart().getLine() == locate.getTextRange().getEndLine())); + + // Check if thread already exists and close thread if issue is closed + if (checkAzureThread(issue, filePath, azureThread, azurePullRequestDetails)) { + threadExists = true; + break; + } + } + if (!issue.getIssue().getStatus().equals(Issue.STATUS_OPEN)) { + LOGGER.info(String.format("SKIPPED ISSUE: Issue status is %s", issue.getIssue().getStatus())); + return; + } + + if (threadExists || !issue.getIssue().getStatus().equals(Issue.STATUS_OPEN)) { + LOGGER.info(String.format("SKIPPED ISSUE: %s %nFile: %s %nLine: %d %nIssue is already exist in azure", + issue.getIssue().getMessage(), + filePath, + line)); + return; + } + + String message = String.format("%s: %s ([rule](%s))%n%n[See in SonarQube](%s)", + issue.getIssue().type().name(), + issue.getIssue().getMessage(), + analysisDetails.getRuleUrlWithRuleKey(issue.getIssue().getRuleKey().toString()), + analysisDetails.getIssueUrl(issue.getIssue().key()) + ); + + CommentThread thread = new CommentThread(filePath, locate, message); + LOGGER.info(String.format("Creating thread: %s", new ObjectMapper().writeValueAsString(thread))); + sendPost( + getThreadApiUrl(azurePullRequestDetails), + new ObjectMapper().writeValueAsString(thread), + "Thread created successfully", + azurePullRequestDetails.getAuthorizationHeader() + ); + } catch (Exception e) { + LOGGER.error("Could not create thread on AzureDevOps Server", e); + throw new IllegalStateException("Could not create thread on AzureDevOps Server", e); + } + } + } + + private boolean checkAzureThread(PostAnalysisIssueVisitor.ComponentIssue issue, String filePath, CommentThread azureThread, + AzurePullRequestDetails azurePullRequestDetails) { + try { + if (azureThread.getThreadContext().getFilePath().equals(filePath) + && azureThread.getComments() + .stream() + .filter(c -> c.getContent().contains(issue.getIssue().key())) + .count() > 0 ) { + + if (!issue.getIssue().getStatus().equals(Issue.STATUS_OPEN) + && azureThread.getStatus() == CommentThreadStatus.ACTIVE) { + Comment comment = new Comment("Closed in SonarQube"); + LOGGER.info("Issue closed in Sonar. try close in Azure"); + sendPost( + azureThread.getLinks().getSelf().getHref() + "/comments" + azurePullRequestDetails.getApiVersion(), + new ObjectMapper().writeValueAsString(comment), + "Comment added success", + azurePullRequestDetails.getAuthorizationHeader() + ); + sendPatch( + azureThread.getLinks().getSelf().getHref() + azurePullRequestDetails.getApiVersion(), + "{\"status\":\"closed\"}", + azurePullRequestDetails.getAuthorizationHeader() + ); + } + return true; + } + return false; + } catch (Exception e) { + LOGGER.error("Could not update thread on AzureDevOps Server", e); + throw new IllegalStateException("Could not update thread on AzureDevOps Server", e); + } + } + + private static String getPullRequestUrl(AzurePullRequestDetails azurePullRequestDetails) { + return azurePullRequestDetails.getAzureUrl() + azurePullRequestDetails.getAzureProjectId() + + "/_git/" + + azurePullRequestDetails.getAzureRepositoryName() + + "/pullRequest/" + + azurePullRequestDetails.getPullRequestId(); + } + + private static String getStatusApiUrl(AzurePullRequestDetails azurePullRequestDetails) { + return azurePullRequestDetails.getAzureUrl() + azurePullRequestDetails.getAzureProjectId() + + "/_apis/git/repositories/" + + azurePullRequestDetails.getAzureRepositoryName() + + "/pullRequests/" + + azurePullRequestDetails.getPullRequestId() + + "/statuses" + + azurePullRequestDetails.getApiVersion(); + } + + private static String getThreadApiUrl(AzurePullRequestDetails azurePullRequestDetails) { + return azurePullRequestDetails.getAzureUrl() + azurePullRequestDetails.getAzureProjectId() + + "/_apis/git/repositories/" + + azurePullRequestDetails.getAzureRepositoryName() + + "/pullRequests/" + + azurePullRequestDetails.getPullRequestId() + + "/threads" + + azurePullRequestDetails.getApiVersion(); + } + + private String getGitPullRequestStatus(AnalysisDetails analysisDetails) throws IOException { + final String gitStatusContextGenre = "SonarQube"; + final String gitStatusContextName = "QualityGate"; + final String gitStatusDescription = "SonarQube Gate"; + + GitPullRequestStatus status = new GitPullRequestStatus( + GitStatusStateMapper.toGitStatusState(analysisDetails.getQualityGateStatus()), + gitStatusDescription, + new GitStatusContext(gitStatusContextGenre, gitStatusContextName), + String.format("%s/dashboard?id=%s&pullRequest=%s", server.getPublicRootUrl(), + URLEncoder.encode(analysisDetails.getAnalysisProjectKey(), + StandardCharsets.UTF_8.name()), + URLEncoder.encode(analysisDetails.getBranchName(), + StandardCharsets.UTF_8.name()) + ) + ); + return new ObjectMapper().writeValueAsString(status); + } + + private void sendPost(String apiUrl, String body, String successMessage, String authorizationHeader) throws IOException { + apiUrl = encodeURI(apiUrl); + LOGGER.trace(String.format("sendPost: URL: %s ", apiUrl)); + LOGGER.trace(String.format("sendPost: BODY: %s ", body)); + HttpPost httpPost = new HttpPost(apiUrl); + addHttpHeaders(httpPost, authorizationHeader); + StringEntity entity = new StringEntity(body, StandardCharsets.UTF_8); + httpPost.setEntity(entity); + + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + HttpResponse httpResponse = httpClient.execute(httpPost); + if (null != httpResponse && httpResponse.getStatusLine().getStatusCode() != 200) { + LOGGER.error("sendPost: " + httpResponse.toString()); + LOGGER.error("sendPost: " + EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8)); + throw new IllegalStateException(GENERAL_ERROR_MESSAGE); + } else if (null != httpResponse) { + LOGGER.debug("sendPost: " + httpResponse.toString()); + LOGGER.info("sendPost: " + successMessage); + } + } + } + + private T sendGet(String apiUrl, Class type, String authorizationHeader) throws IOException { + apiUrl = encodeURI(apiUrl); + LOGGER.info(String.format("sendGet: URL: %s ", apiUrl)); + HttpGet httpGet = new HttpGet(apiUrl); + addHttpHeaders(httpGet, authorizationHeader); + + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + HttpResponse httpResponse = httpClient.execute(httpGet); + if (null != httpResponse && httpResponse.getStatusLine().getStatusCode() != 200) { + LOGGER.error(httpResponse.toString()); + LOGGER.error(EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8)); + throw new IllegalStateException(GENERAL_ERROR_MESSAGE); + } else if (null != httpResponse) { + HttpEntity entity = httpResponse.getEntity(); + T obj = new ObjectMapper() + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true) + .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readValue(IOUtils.toString(entity.getContent(), StandardCharsets.UTF_8), type); + + LOGGER.info(type + " received"); + + return obj; + } else { + throw new IOException("No response reveived"); + } + } + } + + private void sendPatch(String apiUrl, String body, String authorizationHeader) throws IOException { + apiUrl = encodeURI(apiUrl); + LOGGER.trace(String.format("sendPatch: URL: %s ", apiUrl)); + LOGGER.trace(String.format("sendPatch: BODY: %s ", body)); + HttpPatch httpPatch = new HttpPatch(apiUrl); + addHttpHeaders(httpPatch, authorizationHeader); + StringEntity entity = new StringEntity(body, StandardCharsets.UTF_8); + httpPatch.setEntity(entity); + + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + HttpResponse httpResponse = httpClient.execute(httpPatch); + if (null != httpResponse && httpResponse.getStatusLine().getStatusCode() != 200) { + LOGGER.error("sendPatch: " + httpResponse.toString()); + LOGGER.error("sendPatch: " + EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8)); + throw new IllegalStateException(GENERAL_ERROR_MESSAGE); + } else if (null != httpResponse) { + LOGGER.debug("sendPatch: " + httpResponse.toString()); + LOGGER.info("sendPatch: Patch success!"); + } + } + } + + private static HttpRequestBase addHttpHeaders(HttpRequestBase request, String authorizationHeader) { + request.addHeader("Accept", "application/json"); + request.addHeader("Content-Type", "application/json; charset=utf-8"); + request.addHeader("Authorization", authorizationHeader); + + return request; + } + + private static String encodeURI(String uri) throws IOException { + try { + URL url = new URL(uri); + return (new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef())).toString(); + } + catch (Exception ex) { + LOGGER.error(String.format("Error trying to encode URI: %s", uri)); + throw new IOException(String.format("Error trying to encode URI: %s", uri), ex); + } + } + + @Override + public ALM alm() { + return ALM.AZURE_DEVOPS; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/AzurePullRequestDetails.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/AzurePullRequestDetails.java new file mode 100644 index 000000000..77287ecc9 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/AzurePullRequestDetails.java @@ -0,0 +1,66 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import java.util.Base64; +import java.nio.charset.StandardCharsets; + +public class AzurePullRequestDetails { + + public static final String API_VERSION_PREFIX = "?api-version="; + private final String apiVersion; + private final String authorizationHeader; + private final String azureRepositoryName; + private final String azureProjectId; + private final String azureUrl; + private final String baseBranch; + private final String branch; + private final String pullRequestId; + + public AzurePullRequestDetails(String apiVersion, String azureRepositoryName, String azureProjectId, String azureUrl, + String baseBranch, String branch, String personalAccessToken, String pullRequestId) { + this.apiVersion = apiVersion; + this.authorizationHeader = generateAuthorizationHeader(personalAccessToken); + this.azureRepositoryName = azureRepositoryName; + this.azureProjectId = azureProjectId; + this.azureUrl = azureUrl; + this.baseBranch = baseBranch; + this.branch = branch; + this.pullRequestId = pullRequestId; + } + + private static String generateAuthorizationHeader(String apiToken) { + String encodeBytes = Base64.getEncoder().encodeToString((":" + apiToken).getBytes(StandardCharsets.UTF_8)); + return "Basic " + encodeBytes; + } + + public String getApiVersion() { + return API_VERSION_PREFIX + this.apiVersion; + } + + public String getAuthorizationHeader() { + return this.authorizationHeader; + } + + public String getAzureRepositoryName() { + return this.azureRepositoryName; + } + + public String getAzureProjectId() { + return this.azureProjectId; + } + + public String getAzureUrl() { + return this.azureUrl; + } + + public String getBaseBranch() { + return this.baseBranch; + } + + public String getBranch() { + return this.branch; + } + + public String getPullRequestId() { + return this.pullRequestId; + } +} \ No newline at end of file diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Comment.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Comment.java new file mode 100644 index 000000000..387d3cfbd --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Comment.java @@ -0,0 +1,160 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.CommentType; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; + + +/** + * Represents a comment which is one of potentially many in a comment thread. + */ +public class Comment implements Serializable { + + private final String content; + private final CommentType commentType; + private final Integer parentCommentId; + private final int id; + private final int threadId; + private final IdentityRef author; + private final Date publishedDate; + private final Date lastUpdatedDate; + private final Date lastContentUpdatedDate; + @JsonProperty("isDeleted") + private final boolean deleted; + private final List usersLiked; + @JsonProperty("_links") + private final ReferenceLinks links; + + @JsonCreator + public Comment(@JsonProperty("content") String content, + @JsonProperty("commentType") CommentType commentType, + @JsonProperty("parentCommentId") Integer parentCommentId, + @JsonProperty("id") int id, + @JsonProperty("threadId") int threadId, + @JsonProperty("author") IdentityRef author, + @JsonProperty("publishedDate") Date publishedDate, + @JsonProperty("lastUpdatedDate") Date lastUpdatedDate, + @JsonProperty("lastContentUpdatedDate") Date lastContentUpdatedDate, + @JsonProperty("isDeleted") boolean deleted, + @JsonProperty("usersLiked") List usersLiked, + @JsonProperty("_links") ReferenceLinks links) { + + this.content = content; + this.commentType = commentType; + this.parentCommentId = parentCommentId; + this.id = id; + this.threadId = threadId; + this.author = author; + this.publishedDate = publishedDate; + this.lastUpdatedDate = lastUpdatedDate; + this.lastContentUpdatedDate = lastContentUpdatedDate; + this.deleted = deleted; + this.usersLiked = usersLiked; + this.links = links; + } + + public Comment(String content) { + this.content = content; + this.parentCommentId = 0; + this.commentType = CommentType.TEXT; + + this.id = 0; + this.threadId = 0; + this.author = null; + this.publishedDate = null; + this.lastUpdatedDate = null; + this.lastContentUpdatedDate = null; + this.deleted = false; + this.usersLiked = null; + this.links = null; + } + + /** + * The ID of the parent comment. This is used for replies. + */ + public Integer getParentCommentId() { + return this.parentCommentId; + } + + /** + * The comment content. + */ + public String getContent() { + return this.content; + } + + /** + * The comment type at the time of creation. + */ + public CommentType getCommentType() { + return this.commentType; + } + + /** + * The comment ID. IDs start at 1 and are unique to a pull request. + */ + public int getId() { + return this.id; + } + + /** + * The parent thread ID. Used for internal server purposes only -- note + * that this field is not exposed to the REST client. + */ + public int getThreadId() { + return this.threadId; + } + + /** + * The author of the comment. + */ + public IdentityRef getAuthor() { + return this.author; + } + + /** + * The date the comment was first published.; + */ + public Date getPublishedDate() { + return this.publishedDate; + } + + /** + * The date the comment was last updated. + */ + public Date getLastUpdatedDate() { + return this.lastUpdatedDate; + } + + /** + * The date the comment's content was last updated. + */ + public Date getLastContentUpdatedDate() { + return this.lastContentUpdatedDate; + } + + /** + * Whether or not this comment was soft-deleted. + */ + public boolean isDeleted() { + return this.deleted; + } + + /** + * A list of the users who have liked this comment. + */ + public List getUsersLiked() { + return this.usersLiked; + } + + /** + * Links to other related objects. + */ + public ReferenceLinks getLinks() { + return this.links; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentPosition.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentPosition.java new file mode 100644 index 000000000..029562a30 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentPosition.java @@ -0,0 +1,32 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; + +public class CommentPosition implements Serializable { + + private final int line; + private final int offset; + + @JsonCreator + public CommentPosition(@JsonProperty("line") int line, @JsonProperty("offset") int offset){ + this.line = line; + this.offset = offset + 1; + } + + /** + *The line number of a thread's position. Starts at 1. /// + */ + public int getLine() { + return this.line; + } + + /** + *The character offset of a thread's position inside of a line. Starts at 0. + */ + public int getOffset() { + return this.offset; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThread.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThread.java new file mode 100644 index 000000000..00226bec4 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThread.java @@ -0,0 +1,129 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.CommentThreadStatus; +import org.sonar.db.protobuf.DbIssues; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.List; + +/** + * Represents a comment thread of a pull request. A thread contains meta data about the file + * it was left on along with one or more comments (an initial comment and the subsequent replies). + */ +public class CommentThread implements Serializable { + + private final CommentThreadStatus status; + private final List comments; + private final CommentThreadContext threadContext; + private final int id; + private final Date publishedDate; + private final Date lastUpdatedDate; + private final Map identities; + @JsonProperty("isDeleted") + private final boolean deleted; + @JsonProperty("_links") + private final ReferenceLinks links; + + @JsonCreator + public CommentThread(@JsonProperty("status") CommentThreadStatus status, + @JsonProperty("comments") List comments, + @JsonProperty("threadContext") CommentThreadContext context, + @JsonProperty("id") int id, + @JsonProperty("publishedDate") Date publishedDate, + @JsonProperty("lastUpdatedDate") Date lastUpdatedDate, + @JsonProperty("identities") Map identities, + @JsonProperty("isDeleted") boolean deleted, + @JsonProperty("_links") ReferenceLinks links) { + + this.status = status; + this.comments = comments; + this.threadContext = context; + this.id = id; + this.publishedDate = publishedDate; + this.lastUpdatedDate = lastUpdatedDate; + this.identities = identities; + this.deleted = deleted; + this.links = links; + } + + public CommentThread(String filePath, DbIssues.Locations locations, String message) { + this.status = CommentThreadStatus.ACTIVE; + this.comments = Collections.singletonList(new Comment(message)); + this.threadContext = new CommentThreadContext(filePath, locations); + this.id = 0; + this.publishedDate = null; + this.lastUpdatedDate = null; + this.identities = null; + this.deleted = false; + this.links = null; + } + + /** + * A list of the comments. + */ + public List getComments() { + return this.comments; + } + + /** + * The status of the comment thread. + */ + public CommentThreadStatus getStatus() { + return this.status; + } + + /** + * Specify thread context such as position in left/right file. + */ + public CommentThreadContext getThreadContext() { + return this.threadContext; + } + + /** + * The comment thread id. + */ + public int getId() { + return this.id; + } + + /** + * The time this thread was published. + */ + public Date getPublishedDate() { + return this.publishedDate; + } + + /** + * The time this thread was last updated. + */ + public Date getLastUpdatedDate() { + return this.lastUpdatedDate; + } + + /** + * Set of identities related to this thread + */ + public Map getIdentities() { + return this.identities; + } + + /** + * Specify if the thread is deleted which happens when all comments are deleted. + */ + public boolean isDeleted() { + return this.deleted; + } + + /** + * Links to other related objects. + */ + public ReferenceLinks getLinks() { + return this.links; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadContext.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadContext.java new file mode 100644 index 000000000..27f3bd102 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadContext.java @@ -0,0 +1,77 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.sonar.db.protobuf.DbIssues; + +import java.io.Serializable; + +public class CommentThreadContext implements Serializable { + + private final String filePath; + private final CommentPosition leftFileStart; + private final CommentPosition leftFileEnd; + private final CommentPosition rightFileStart; + private final CommentPosition rightFileEnd; + + @JsonCreator + public CommentThreadContext(@JsonProperty("filePath") String filePath, @JsonProperty("leftFileStart") CommentPosition leftFileStart, + @JsonProperty("leftFileEnd") CommentPosition leftFileEnd, @JsonProperty("rightFileStart") CommentPosition rightFileStart, + @JsonProperty("rightFileEnd") CommentPosition rightFileEnd) { + + this.filePath = filePath; + this.leftFileStart = leftFileStart; + this.leftFileEnd = leftFileEnd; + this.rightFileStart = rightFileStart; + this.rightFileEnd = rightFileEnd; + } + + public CommentThreadContext(String filePath, DbIssues.Locations locations) { + this.filePath = filePath; + this.leftFileEnd = null; + this.leftFileStart = null; + this.rightFileEnd = new CommentPosition( + locations.getTextRange().getEndLine(), + locations.getTextRange().getEndOffset() + ); + this.rightFileStart = new CommentPosition( + locations.getTextRange().getStartLine(), + locations.getTextRange().getStartOffset() + ); + } + + /** + * File path relative to the root of the repository. It's up to the client to + */ + public String getFilePath() { + return this.filePath; + } + + /** + * Position of first character of the thread's span in left file. /// + */ + public CommentPosition getLeftFileStart() { + return this.leftFileStart; + } + + /** + * Position of last character of the thread's span in left file. /// + */ + public CommentPosition getLeftFileEnd() { + return this.leftFileEnd; + } + + /** + * Position of first character of the thread's span in right file. /// + */ + public CommentPosition getRightFileStart() { + return this.rightFileStart; + } + + /** + * Position of last character of the thread's span in right file. /// + */ + public CommentPosition getRightFileEnd() { + return this.rightFileEnd; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadResponse.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadResponse.java new file mode 100644 index 000000000..e0d9d8303 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/CommentThreadResponse.java @@ -0,0 +1,18 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class CommentThreadResponse { + + private final CommentThread[] value; + + @JsonCreator + public CommentThreadResponse(@JsonProperty("value") CommentThread[] value) { + this.value = value; + } + + public CommentThread[] getValue() { + return this.value; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitPullRequestStatus.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitPullRequestStatus.java new file mode 100644 index 000000000..be9cca819 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitPullRequestStatus.java @@ -0,0 +1,53 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.GitStatusState; + +public class GitPullRequestStatus { + + private final GitStatusState state; + private final String description; + private final GitStatusContext context; + private final String targetUrl; + + @JsonCreator + public GitPullRequestStatus( + @JsonProperty("state") GitStatusState state, + @JsonProperty("description") String description, + @JsonProperty("context") GitStatusContext context, + @JsonProperty("targetUrl") String targetUrl) { + this.state = state; + this.description = description; + this.context = context; + this.targetUrl = targetUrl; + } + + /** + * State of the status. + */ + public GitStatusState getState() { + return this.state; + } + + /** + * Description of the status + */ + public String getDescription() { + return this.description; + } + + /** + * Status context that uniquely identifies the status. + */ + public GitStatusContext getContext() { + return this.context; + } + + /** + * TargetUrl of the status + */ + public String getTargetUrl() { + return this.targetUrl; + } +} \ No newline at end of file diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitStatusContext.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitStatusContext.java new file mode 100644 index 000000000..a11184d50 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/GitStatusContext.java @@ -0,0 +1,33 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Status context that uniquely identifies the status. + */ +public class GitStatusContext { + + private final String name; + private final String genre; + + @JsonCreator + public GitStatusContext(@JsonProperty("genre") String genre, @JsonProperty("name") String name){ + this.genre = genre; + this.name = name; + } + + /** + * Genre of the status. Typically name of the service/tool generating the status, can be empty. + */ + public String getGenre() { + return this.genre; + } + + /** + * Name identifier of the status, cannot be null or empty. + */ + public String getName() { + return this.name; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/IdentityRef.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/IdentityRef.java new file mode 100644 index 000000000..e2bd9fa61 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/IdentityRef.java @@ -0,0 +1,153 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; + +public class IdentityRef implements Serializable { + + private final String displayName; + private final String url; + @JsonProperty("_links") + private final ReferenceLinks links; + private final String id; + private final String uniqueName; + private final String directoryAlias; + private final String profileUrl; + private final String imageUrl; + @JsonProperty("isContainer") + private final boolean container; + @JsonProperty("isAadIdentity") + private final boolean aadIdentity; + @JsonProperty("isInactive") + private final boolean inactive; + @JsonProperty("isDeletedInOrigin") + private final boolean deletedInOrigin; + private final String displayNameForXmlSerialization; + private final String urlForXmlSerialization; + + @JsonCreator + public IdentityRef(@JsonProperty("displayName") String displayName, + @JsonProperty("url") String url, + @JsonProperty("_links") ReferenceLinks links, + @JsonProperty("id") String id, + @JsonProperty("uniqueName") String uniqueName, + @JsonProperty("directoryAlias") String directoryAlias, + @JsonProperty("profileUrl") String profileUrl, + @JsonProperty("imageUrl") String imageUrl, + @JsonProperty("isContainer") boolean container, + @JsonProperty("isAadIdentity") boolean aadIdentity, + @JsonProperty("isInactive") boolean inactive, + @JsonProperty("isDeletedInOrigin") boolean deletedInOrigin, + @JsonProperty("displayNameForXmlSerialization") String displayNameForXmlSerialization, + @JsonProperty("urlForXmlSerialization") String urlForXmlSerialization) { + + this.displayName = displayName; + this.url = url; + this.links = links; + this.id = id; + this.uniqueName = uniqueName; + this.directoryAlias = directoryAlias; + this.profileUrl = profileUrl; + this.imageUrl = imageUrl; + this.container = container; + this.aadIdentity = aadIdentity; + this.inactive = inactive; + this.deletedInOrigin = deletedInOrigin; + this.displayNameForXmlSerialization = displayNameForXmlSerialization; + this.urlForXmlSerialization = urlForXmlSerialization; + } + + public String getDisplayName() { + return this.displayName; + } + + public String getUrl() { + return this.url; + } + + public ReferenceLinks getLinks() { + return this.links; + } + + public String getId() { + return this.id; + } + + /** + * @deprecated - use Domain+PrincipalName instead + */ + @Deprecated + public String getUniqueName() { + return this.uniqueName; + } + + /** + * @deprecated - Can be retrieved by querying the Graph user referenced in the + * "self" entry of the IdentityRef "_links" dictionary + */ + @Deprecated + public String getDirectoryAlias() { + return this.directoryAlias; + } + + /** + * @deprecated - not in use in most preexisting implementations of ToIdentityRef + */ + @Deprecated + public String getProfileUrl() { + return this.profileUrl; + } + + /** + * @deprecated - Available in the "avatar" entry of the IdentityRef "_links" dictionary + */ + @Deprecated + public String getImageUrl() { + return this.imageUrl; + } + + /** + * @deprecated - Can be inferred from the subject type of the descriptor (Descriptor.IsGroupType) + */ + @Deprecated + public boolean isContainer() { + return this.container; + } + + /** + * @deprecated - Can be inferred from the subject type of the descriptor (Descriptor.IsAadUserType/Descriptor.IsAadGroupType) + */ + @Deprecated + public boolean isAadIdentity() { + return this.aadIdentity; + } + + /** + * @deprecated - Can be retrieved by querying the Graph membership state referenced + * in the "membershipState" entry of the GraphUser "_links" dictionary + */ + @Deprecated + public boolean getInactive() { + return this.inactive; + } + + public boolean getIsDeletedInOrigin() { + return this.deletedInOrigin; + } + + /** + * This property is for xml compat only. + */ + public String getdisplayNameForXmlSerialization() { + return this.displayNameForXmlSerialization; + } + + /** + * This property is for xml compat only. + */ + public String getUrlForXmlSerialization() { + return this.urlForXmlSerialization; + } +} \ No newline at end of file diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Link.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Link.java new file mode 100644 index 000000000..69a05ff21 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/Link.java @@ -0,0 +1,20 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; + +public class Link implements Serializable { + + private final String href; + + @JsonCreator + public Link(@JsonProperty("href") String href) { + this.href = href; + } + + public String getHref() { + return this.href; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/ReferenceLinks.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/ReferenceLinks.java new file mode 100644 index 000000000..2111f34f4 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/ReferenceLinks.java @@ -0,0 +1,20 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; + +public class ReferenceLinks implements Serializable { + + private final Link self; + + @JsonCreator + public ReferenceLinks(@JsonProperty("self") Link self) { + this.self = self; + } + + public Link getSelf() { + return this.self; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentThreadStatus.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentThreadStatus.java new file mode 100644 index 000000000..78e2b105e --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentThreadStatus.java @@ -0,0 +1,36 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums; + +/** + * The status of a comment thread + */ +public enum CommentThreadStatus { + /** + * The thread status is unknown. + */ + UNKNOWN, + /** + * The thread status is active. + */ + ACTIVE, + /** + * The thread status is resolved as fixed. + */ + FIXED, + /** + * The thread status is resolved as won't fix. + */ + WONT_FIX, + /** + * The thread status is closed. + */ + CLOSED, + /** + * The thread status is resolved as by design. + */ + BY_DESIGN, + /** + * The thread status is pending. + */ + PENDING +} + diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentType.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentType.java new file mode 100644 index 000000000..50810d432 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/CommentType.java @@ -0,0 +1,20 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums; + +public enum CommentType { + /** + * The comment type is not known. + */ + UNKNOWN, + /** + * This is a regular user comment. + */ + TEXT, + /** + * The comment comes as a result of a code change. + */ + CODE_CHANGE, + /** + * The comment represents a system message. + */ + SYSTEM +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/GitStatusState.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/GitStatusState.java new file mode 100644 index 000000000..2df916e07 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/enums/GitStatusState.java @@ -0,0 +1,32 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums; + +/** + * State of the status. + */ +public enum GitStatusState +{ + /** + * Status state not set. Default state. + */ + NOT_SET, + /** + * Status pending. + */ + PENDING, + /** + * Status succeeded. + */ + SUCCEEDED, + /** + * Status failed. + */ + FAILED, + /** + * Status with an error. + */ + ERROR, + /** + * Status is not applicable to the target object. + */ + NOT_APPLICABLE +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/CommentThreadStatusMapper.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/CommentThreadStatusMapper.java new file mode 100644 index 000000000..0a3a406f8 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/CommentThreadStatusMapper.java @@ -0,0 +1,29 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.mappers; + +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.CommentThreadStatus; + +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.api.issue.Issue.STATUS_CLOSED; + + +public final class CommentThreadStatusMapper { + + private CommentThreadStatusMapper() { + super(); + } + + public static CommentThreadStatus toCommentThreadStatus(String issueStatus) { + if (issueStatus.equals(STATUS_OPEN)) { + return CommentThreadStatus.ACTIVE; + } else { + return CommentThreadStatus.FIXED; + } + } + public static String toIssueStatus(CommentThreadStatus commentThreadStatus) { + if (commentThreadStatus == CommentThreadStatus.ACTIVE) { + return STATUS_OPEN; + } else { + return STATUS_CLOSED; + } + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/GitStatusStateMapper.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/GitStatusStateMapper.java new file mode 100644 index 000000000..1dc93e375 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/model/mappers/GitStatusStateMapper.java @@ -0,0 +1,22 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.mappers; + +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.enums.GitStatusState; +import org.sonar.api.ce.posttask.QualityGate; + +public final class GitStatusStateMapper { + + private GitStatusStateMapper() { + super(); + } + + public static GitStatusState toGitStatusState(QualityGate.Status analysisStatus) { + switch (analysisStatus) { + case OK: + return GitStatusState.SUCCEEDED; + case ERROR: + return GitStatusState.ERROR; + default: + return GitStatusState.NOT_SET; + } + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensor.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensor.java index b17bb71b1..235f59b69 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensor.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensor.java @@ -18,20 +18,25 @@ */ package com.github.mc1arke.sonarqube.plugin.scanner; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.GitlabServerPullRequestDecorator; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.AzureDevOpsServerPullRequestDecorator; import org.sonar.api.batch.sensor.Sensor; import org.sonar.api.batch.sensor.SensorContext; import org.sonar.api.batch.sensor.SensorDescriptor; import org.sonar.api.utils.System2; +import org.sonar.scanner.scan.ProjectConfiguration; import java.util.Optional; public class ScannerPullRequestPropertySensor implements Sensor { + private final ProjectConfiguration projectConfiguration; private final System2 system2; - public ScannerPullRequestPropertySensor(System2 system2) { + public ScannerPullRequestPropertySensor(ProjectConfiguration projectConfiguration, System2 system2) { super(); + this.projectConfiguration = projectConfiguration; this.system2 = system2; } @@ -61,6 +66,39 @@ public void execute(SensorContext sensorContext) { v -> sensorContext.addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL, v)); Optional.ofNullable(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID)).ifPresent( v -> sensorContext.addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID, v)); + + // AZURE DEVOPS + + // Look for Predefined Environment Variables first + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_INSTANCE_URL)).ifPresent( + v -> sensorContext.addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL, v)); + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_TEAMPROJECT_ID)).ifPresent( + v -> sensorContext.addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID, v)); + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_REPOSITORY_NAME)).ifPresent( + v -> sensorContext.addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME, v)); + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_BASE_BRANCH)).ifPresent( + v -> sensorContext.addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BASE, v)); + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_BRANCH)).ifPresent( + v -> sensorContext.addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BRANCH, v)); + Optional.ofNullable(system2.property(AzureDevOpsServerPullRequestDecorator.AZUREDEVOPS_ENV_PULLREQUEST_ID)).ifPresent( + v -> sensorContext.addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_KEY, v)); + + // Overwrite with scanner properties + projectConfiguration.get(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL).ifPresent(v -> sensorContext + .addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL, v)); + projectConfiguration.get(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID).ifPresent(v -> sensorContext + .addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID, v)); + projectConfiguration.get(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME).ifPresent(v -> sensorContext + .addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME, v)); + projectConfiguration.get(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BASE).ifPresent(v -> sensorContext + .addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BASE, v)); + projectConfiguration.get(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BRANCH).ifPresent(v -> sensorContext + .addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_BRANCH, v)); + projectConfiguration.get(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_KEY).ifPresent(v -> sensorContext + .addContextProperty(AnalysisDetails.SCANNERROPERTY_PULLREQUEST_KEY, v)); + projectConfiguration.get(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_API_VERSION).ifPresent(v -> sensorContext + .addContextProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_API_VERSION, v)); + } } diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProviderTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProviderTest.java index 5737399a3..16a2b7622 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProviderTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/CommunityReportAnalysisComponentProviderTest.java @@ -32,7 +32,7 @@ public class CommunityReportAnalysisComponentProviderTest { @Test public void testGetComponents() { List result = new CommunityReportAnalysisComponentProvider().getComponents(); - assertEquals(9, result.size()); + assertEquals(10, result.size()); assertEquals(CommunityBranchLoaderDelegate.class, result.get(0)); } } diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetailsTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetailsTest.java index f4e0e7842..493f0a696 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetailsTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/AnalysisDetailsTest.java @@ -757,4 +757,29 @@ public void testGetBaseImageUrlFromRootUrl() { assertEquals("http://localhost:9000/static/communityBranchPlugin", analysisDetails.getBaseImageUrl()); } + @Test + public void testGetIssueUrl() { + Project project = mock(Project.class); + doReturn("projectKey").when(project).getKey(); + + AnalysisDetails.BranchDetails branchDetails = mock(AnalysisDetails.BranchDetails.class); + doReturn("123").when(branchDetails).getBranchName(); + + AnalysisDetails analysisDetails = + new AnalysisDetails(branchDetails, mock(PostAnalysisIssueVisitor.class), + mock(QualityGate.class), mock(AnalysisDetails.MeasuresHolder.class), + mock(Analysis.class), project, mock(Configuration.class), "http://localhost:9000", mock(ScannerContext.class)); + + assertEquals("http://localhost:9000/project/issues?id=projectKey&pullRequest=123&issues=issueKey&open=issueKey", analysisDetails.getIssueUrl("issueKey")); + } + + @Test + public void testGetRuleUrlWithRuleKey() { + AnalysisDetails analysisDetails = + new AnalysisDetails(mock(AnalysisDetails.BranchDetails.class), mock(PostAnalysisIssueVisitor.class), + mock(QualityGate.class), mock(AnalysisDetails.MeasuresHolder.class), + mock(Analysis.class), mock(Project.class), mock(Configuration.class), "http://localhost:9000", mock(ScannerContext.class)); + + assertEquals("http://localhost:9000/coding_rules?open=ruleKey&rule_key=ruleKey", analysisDetails.getRuleUrlWithRuleKey("ruleKey")); + } } diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecoratorTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecoratorTest.java new file mode 100644 index 000000000..441dee60f --- /dev/null +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsServerPullRequestDecoratorTest.java @@ -0,0 +1,429 @@ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops; + +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.model.AzurePullRequestDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PostAnalysisIssueVisitor; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import org.apache.commons.lang3.StringEscapeUtils; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.ce.posttask.QualityGate; +import org.sonar.api.issue.Issue; +import org.sonar.api.platform.Server; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.rules.RuleType; +import org.sonar.ce.task.projectanalysis.component.Component; +import org.sonar.core.issue.DefaultIssue; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.protobuf.DbIssues; + +import java.util.Base64; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.Assert.assertEquals; + +public class AzureDevOpsServerPullRequestDecoratorTest { + @Rule + public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig()); + + private ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); + private AlmSettingDto almSettingDto = mock(AlmSettingDto.class); + private Server server = mock(Server.class); + private AzureDevOpsServerPullRequestDecorator pullRequestDecorator = new AzureDevOpsServerPullRequestDecorator(server); + private AnalysisDetails analysisDetails = mock(AnalysisDetails.class); + + @Test + public void testName() { + assertThat(new AzureDevOpsServerPullRequestDecorator(mock(Server.class)).alm()).isEqualTo(ALM.AZURE_DEVOPS); + } + + @Test + public void testDecorateQualityGateInstanceURLException() { + Exception dummyException = new IllegalStateException(String.format("Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties", + AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))).thenReturn(Optional.of("repo")); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))).thenReturn(Optional.of("prj")); + when(analysisDetails.getPullRequestBase()).thenReturn(Optional.of("master")); + when(analysisDetails.getPullRequestBranch()).thenReturn(Optional.of("pr")); + doThrow(dummyException).when(analysisDetails).getScannerProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void testDecorateQualityGateRepoNameException() { + Exception dummyException = new IllegalStateException(String.format("Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties", + AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))).thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))).thenReturn(Optional.of("prj")); + when(analysisDetails.getPullRequestBase()).thenReturn(Optional.of("master")); + when(analysisDetails.getPullRequestBranch()).thenReturn(Optional.of("pr")); + doThrow(dummyException).when(analysisDetails).getScannerProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void testDecorateQualityGateProjectIDException() { + Exception dummyException = new IllegalStateException(String.format("Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties", + AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))).thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))).thenReturn(Optional.of("repo")); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getPullRequestBase()).thenReturn(Optional.of("master")); + when(analysisDetails.getPullRequestBranch()).thenReturn(Optional.of("pr")); + doThrow(dummyException).when(analysisDetails).getScannerProperty(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void testDecorateQualityGatePRBranchException() { + Exception dummyException = new IllegalStateException(String.format("Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties", + analysisDetails.SCANNERROPERTY_PULLREQUEST_BRANCH)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))).thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))).thenReturn(Optional.of("repo")); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))).thenReturn(Optional.of("prj")); + when(analysisDetails.getPullRequestBase()).thenReturn(Optional.of("master")); + doThrow(dummyException).when(analysisDetails).getPullRequestBranch(); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void testDecorateQualityGatePRBaseException() { + Exception dummyException = new IllegalStateException(String.format("Could not decorate AzureDevOps pullRequest. '%s' has not been set in scanner properties", + analysisDetails.SCANNERROPERTY_PULLREQUEST_BASE)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))).thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))).thenReturn(Optional.of("repo")); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))).thenReturn(Optional.of("prj")); + when(analysisDetails.getPullRequestBranch()).thenReturn(Optional.of("pr")); + doThrow(dummyException).when(analysisDetails).getPullRequestBase(); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void testDecorateQualityGateAccessTokenException() { + Exception dummyException = new IllegalStateException("Could not decorate AzureDevOps pullRequest. Access token has not been set"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))).thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))).thenReturn(Optional.of("repo")); + when(analysisDetails.getBranchName()).thenReturn("123"); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))).thenReturn(Optional.of("prj")); + when(analysisDetails.getPullRequestBranch()).thenReturn(Optional.of("pr")); + when(analysisDetails.getPullRequestBase()).thenReturn(Optional.of("master")); + when(almSettingDto.getPersonalAccessToken()).thenReturn(null); + + assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .hasMessage("Could not decorate Pull Request on AzureDevOps Server") + .isExactlyInstanceOf(IllegalStateException.class).hasCause(dummyException); + } + + @Test + public void decorateQualityGateStatus() { + String apiVersion = "5.1-preview.1"; + String azureProject = "azure Project"; + String sonarProject = "sonarProject"; + String pullRequestId = "8513"; + String baseBranchName = "master"; + String branchName = "feature/some-feature"; + String azureRepository = "my Repository"; + String sonarRootUrl = "http://sonar:9000/sonar"; + String filePath = "path/to/file"; + String issueMessage = "issueMessage"; + String issueKeyVal = "issueKeyVal"; + String ruleKeyVal = "ruleKeyVal"; + String issueUrl = "http://sonar:9000/sonar/project/issues?id=sonarProject&pullRequest=8513&issues=issueKeyVal&open=issueKeyVal"; + String ruleUrl = "http://sonar:9000/sonar/coding_rules?open=ruleKeyVal&rule_key=ruleKeyVal"; + int lineNumber = 5; + String token = "token"; + String authHeader = "Basic OnRva2Vu"; + + AzurePullRequestDetails azurePullRequestDetails = mock(AzurePullRequestDetails.class); + PostAnalysisIssueVisitor issueVisitor = mock(PostAnalysisIssueVisitor.class); + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + DefaultIssue defaultIssue = mock(DefaultIssue.class); + Component component = mock(Component.class); + + when(almSettingDto.getPersonalAccessToken()).thenReturn(token); + + when(azurePullRequestDetails.getAuthorizationHeader()) + .thenReturn(authHeader); + when(azurePullRequestDetails.getApiVersion()) + .thenReturn(azurePullRequestDetails.API_VERSION_PREFIX + apiVersion); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_INSTANCE_URL))) + .thenReturn(Optional.of(wireMockRule.baseUrl())); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_REPOSITORY_NAME))) + .thenReturn(Optional.of(azureRepository)); + when(analysisDetails.getBranchName()) + .thenReturn(pullRequestId); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_PROJECT_ID))) + .thenReturn(Optional.of(azureProject)); + when(analysisDetails.getPullRequestBase()) + .thenReturn(Optional.of(baseBranchName)); + when(analysisDetails.getPullRequestBranch()) + .thenReturn(Optional.of(branchName)); + when(analysisDetails.getScannerProperty(eq(AzureDevOpsServerPullRequestDecorator.PULLREQUEST_AZUREDEVOPS_API_VERSION))) + .thenReturn(Optional.of(apiVersion)); + when(analysisDetails.getAnalysisProjectKey()).thenReturn(sonarProject); + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); + when(analysisDetails.getBranchName()).thenReturn(pullRequestId); + when(analysisDetails.getPostAnalysisIssueVisitor()).thenReturn(issueVisitor); + when(analysisDetails.getRuleUrlWithRuleKey(ruleKeyVal)).thenReturn(ruleUrl); + when(analysisDetails.getIssueUrl(issueKeyVal)).thenReturn(issueUrl); + when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of(filePath)); + when(issueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); + + DbIssues.Locations locate = DbIssues.Locations.newBuilder().build(); + RuleType rule = RuleType.CODE_SMELL; + RuleKey ruleKey = mock(RuleKey.class); + when(componentIssue.getIssue()).thenReturn(defaultIssue); + when(componentIssue.getComponent()).thenReturn(component); + when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(defaultIssue.getLine()).thenReturn(lineNumber); + when(defaultIssue.getLocations()).thenReturn(locate); + when(defaultIssue.type()).thenReturn(rule); + when(defaultIssue.getMessage()).thenReturn(issueMessage); + when(defaultIssue.getRuleKey()).thenReturn(ruleKey); + when(defaultIssue.key()).thenReturn(issueKeyVal); + when(ruleKey.toString()).thenReturn(ruleKeyVal); + + wireMockRule.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads"+ azurePullRequestDetails.getApiVersion())) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(azurePullRequestDetails.getAuthorizationHeader())) + .willReturn(aResponse() + .withStatus(200) + .withBody( + "{" + System.lineSeparator() + + " \"value\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"pullRequestThreadContext\": {" + System.lineSeparator() + + " \"iterationContext\": {" + System.lineSeparator() + + " \"firstComparingIteration\": 1," + System.lineSeparator() + + " \"secondComparingIteration\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changeTrackingId\": 4" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": 80450," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"comments\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"id\": 1," + System.lineSeparator() + + " \"parentCommentId\": 0," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"displayName\": \"More text\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"avatar\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": \"c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"uniqueName\": \"user@mail.ru\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"descriptor\": \"win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"lastContentUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"commentType\": \"text\"," + System.lineSeparator() + + " \"usersLiked\": []," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450/comments/1\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"threadContext\": {" + System.lineSeparator() + + " \"filePath\": \"/" + azureProject + "/" + azureRepository + "/Helpers/file.cs\"," + System.lineSeparator() + + " \"rightFileStart\": {" + System.lineSeparator() + + " \"line\": 18," + System.lineSeparator() + + " \"offset\": 11" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"rightFileEnd\": {" + System.lineSeparator() + + " \"line\": 18," + System.lineSeparator() + + " \"offset\": 15" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"properties\": {}," + System.lineSeparator() + + " \"identities\": null," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"pullRequestThreadContext\": {" + System.lineSeparator() + + " \"iterationContext\": {" + System.lineSeparator() + + " \"firstComparingIteration\": 1," + System.lineSeparator() + + " \"secondComparingIteration\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changeTrackingId\": 13" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": 80452," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T19:06:11.37Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T19:06:11.37Z\"," + System.lineSeparator() + + " \"comments\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"id\": 1," + System.lineSeparator() + + " \"parentCommentId\": 0," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"displayName\": \"text\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"avatar\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": \"c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"uniqueName\": \"user@mail.ru\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"descriptor\": \"win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"content\": \"Comment\"," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T19:06:11.37Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T19:06:11.37Z\"," + System.lineSeparator() + + " \"lastContentUpdatedDate\": \"2020-03-10T19:06:11.37Z\"," + System.lineSeparator() + + " \"commentType\": \"text\"," + System.lineSeparator() + + " \"usersLiked\": []," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80452/comments/1\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"threadContext\": {" + System.lineSeparator() + + " \"filePath\": \"/" + azureProject + "/" + azureRepository + "/Helpers/file2.cs\"," + System.lineSeparator() + + " \"rightFileStart\": {" + System.lineSeparator() + + " \"line\": 30," + System.lineSeparator() + + " \"offset\": 57" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"rightFileEnd\": {" + System.lineSeparator() + + " \"line\": 30," + System.lineSeparator() + + " \"offset\": 65" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"properties\": {}," + System.lineSeparator() + + " \"identities\": null," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80452\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"count\": 2" + System.lineSeparator() + + "}"))); + + wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads"+ azurePullRequestDetails.getApiVersion())) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(azurePullRequestDetails.getAuthorizationHeader())) + .withRequestBody(equalTo("{" + + "\"status\":\"ACTIVE\"," + + "\"comments\":[{" + + "\"content\":\"CODE_SMELL: issueMessage ([rule](" + sonarRootUrl + "/coding_rules?open=" + ruleKeyVal + "&rule_key=" + ruleKeyVal + "))"+StringEscapeUtils.escapeJava(System.lineSeparator())+StringEscapeUtils.escapeJava(System.lineSeparator())+"[See in SonarQube](" + sonarRootUrl + "/project/issues?id=" + sonarProject + "&pullRequest=" + pullRequestId + "&issues=" + issueKeyVal + "&open=" + issueKeyVal + ")\"," + + "\"commentType\":\"TEXT\"," + + "\"parentCommentId\":0," + + "\"id\":0," + + "\"threadId\":0," + + "\"author\":null," + + "\"publishedDate\":null," + + "\"lastUpdatedDate\":null," + + "\"lastContentUpdatedDate\":null," + + "\"isDeleted\":false," + + "\"usersLiked\":null," + + "\"_links\":null}]," + + "\"threadContext\":{" + + "\"filePath\":\"/" + filePath + "\"," + + "\"leftFileStart\":null," + + "\"leftFileEnd\":null," + + "\"rightFileStart\":{\"line\":0," + + "\"offset\":1}," + + "\"rightFileEnd\":{\"line\":0," + + "\"offset\":1}}," + + "\"id\":0," + + "\"publishedDate\":null," + + "\"lastUpdatedDate\":null," + + "\"identities\":null," + + "\"isDeleted\":false," + + "\"_links\":null" + + "}") + ) + .willReturn(ok())); + + + wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/statuses"+ azurePullRequestDetails.getApiVersion())) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(azurePullRequestDetails.getAuthorizationHeader())) + .withRequestBody(equalTo("{" + + "\"state\":\"SUCCEEDED\"," + + "\"description\":\"SonarQube Gate\"," + + "\"context\":{\"genre\":\"SonarQube\",\"name\":\"QualityGate\"}," + + "\"targetUrl\":\"" + sonarRootUrl + "/dashboard?id=" + sonarProject + "&pullRequest=" + pullRequestId + "\"" + + "}") + ) + .willReturn(ok())); + + wireMockRule.stubFor(patch(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/threads/80450/comments/1"+ azurePullRequestDetails.getApiVersion())) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(azurePullRequestDetails.getAuthorizationHeader())) + .withRequestBody(equalTo("{" + + "\"status\":\"closed\"" + + "}") + ) + .willReturn(ok())); + + when(server.getPublicRootUrl()).thenReturn(sonarRootUrl); + + pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + } + +}