From a032a916f825cfd088b76405535897d623847cbc Mon Sep 17 00:00:00 2001 From: Michael Clarke Date: Sun, 4 Jul 2021 16:51:52 +0100 Subject: [PATCH] #141: Resolve or retain Gitlab comments rather than deleting them The Gitlab decorator currently deletes all non-system comments posted by the user that Sonarqube is connected as and then creates comments for any issues reported in the current analysis, even where an issue is the same across multiple analyses of a Merge Request so would have an identical comment re-created under a new discussion. This causes issues with orphaned system comments - such as `UserXXX changed this line in version X of the diff` - and user discussions referring to Sonarqube's finding that no longer have the finding in the thread. The decorator will now attempt to parse the comment's issue ID from the SonarQube link included in each comment and will close any discussion where the ID no longer exists in the analysis results, unless another user has also commented on the discussion in which case the decorator will comment that the issue is resolved but will leave the discussion open for manual review. Where the ID is not parsable a warning is logged and the discussion is left open. To simplify the code within the decorator, the interactions with the Gitlab API have been pulled out into a separate GitlabClient, thereby allowing consistent authentication and error handling for Gitlab calls. --- build.gradle | 11 +- ...munityReportAnalysisComponentProvider.java | 7 +- .../ce/pullrequest/AnalysisDetails.java | 23 +- .../v3 => }/DefaultLinkHeaderReader.java | 4 +- .../{github/v3 => }/LinkHeaderReader.java | 6 +- ...RestApplicationAuthenticationProvider.java | 1 + .../gitlab/DefaultGitlabClientFactory.java | 41 + .../ce/pullrequest/gitlab/GitlabClient.java | 49 ++ .../gitlab/GitlabClientFactory.java | 24 + .../gitlab/GitlabMergeRequestDecorator.java | 381 +++++++++ .../pullrequest/gitlab/GitlabRestClient.java | 228 ++++++ .../GitlabServerPullRequestDecorator.java | 408 ---------- .../gitlab/{response => model}/Commit.java | 2 +- .../pullrequest/gitlab/model/CommitNote.java | 63 ++ .../ce/pullrequest/gitlab/model/DiffRefs.java | 45 ++ .../{response => model}/Discussion.java | 8 +- .../gitlab/model/MergeRequest.java | 53 ++ .../gitlab/model/MergeRequestNote.java | 32 + .../gitlab/{response => model}/Note.java | 29 +- .../gitlab/model/PipelineStatus.java | 80 ++ .../gitlab/{response => model}/User.java | 2 +- .../pullrequest/gitlab/response/DiffRefs.java | 27 - .../gitlab/response/MergeRequest.java | 33 - .../ScannerPullRequestPropertySensor.java | 26 +- ...tyReportAnalysisComponentProviderTest.java | 2 +- .../ce/pullrequest/AnalysisDetailsTest.java | 44 ++ .../DefaultLinkHeaderReaderTest.java | 60 ++ .../v3/DefaultLinkHeaderReaderTest.java | 92 --- ...ApplicationAuthenticationProviderTest.java | 1 + ...bMergeRequestDecoratorIntegrationTest.java | 264 +++++++ .../GitlabMergeRequestDecoratorTest.java | 731 ++++++++++++++++++ .../GitlabServerPullRequestDecoratorTest.java | 215 ------ .../ScannerPullRequestPropertySensorTest.java | 10 +- src/test/resources/logback-test.xml | 12 + 34 files changed, 2199 insertions(+), 815 deletions(-) rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/{github/v3 => }/DefaultLinkHeaderReader.java (94%) rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/{github/v3 => }/LinkHeaderReader.java (85%) create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/DefaultGitlabClientFactory.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClient.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClientFactory.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecorator.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabRestClient.java delete mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecorator.java rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/{response => model}/Commit.java (98%) create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/CommitNote.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/DiffRefs.java rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/{response => model}/Discussion.java (99%) create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequest.java create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequestNote.java rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/{response => model}/Note.java (66%) create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/PipelineStatus.java rename src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/{response => model}/User.java (99%) delete mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/DiffRefs.java delete mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/MergeRequest.java create mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReaderTest.java delete mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReaderTest.java create mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorIntegrationTest.java create mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorTest.java delete mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecoratorTest.java create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle b/build.gradle index 08ababa80..83fcaad7f 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,6 @@ compileJava { dependencies { compileOnly fileTree(dir: sonarLibraries, include: '**/*.jar', exclude: 'extensions/*.jar') testCompile fileTree(dir: sonarLibraries, include: '**/*.jar', exclude: 'extensions/*.jar') - testCompile group: 'junit', name: 'junit', version: '4.12' testCompile group: 'org.mockito', name: 'mockito-core', version: '3.1.0' testCompile group: 'org.assertj', name: 'assertj-core', version: '3.13.2' testCompile group: 'com.github.tomakehurst', name: 'wiremock', version: '2.24.1' @@ -66,6 +65,9 @@ dependencies { compileOnly 'com.google.code.findbugs:jsr305:3.0.2' compile 'org.javassist:javassist:3.27.0-GA' implementation 'com.squareup.okhttp3:logging-interceptor:4.9.0' + testImplementation(platform('org.junit:junit-bom:5.7.2')) + testImplementation('org.junit.jupiter:junit-jupiter') + testRuntime("org.junit.vintage:junit-vintage-engine") } @@ -137,3 +139,10 @@ jacocoTestReport { plugins.withType(JacocoPlugin) { tasks["test"].finalizedBy 'jacocoTestReport' } + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} 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 736f832e1..e234b95ee 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 @@ -18,15 +18,16 @@ */ package com.github.mc1arke.sonarqube.plugin.ce; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.DefaultLinkHeaderReader; 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; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.RestApplicationAuthenticationProvider; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v4.GraphqlCheckRunProvider; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.GitlabServerPullRequestDecorator; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.DefaultGitlabClientFactory; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.GitlabMergeRequestDecorator; import org.sonar.ce.task.projectanalysis.container.ReportAnalysisComponentProvider; import java.util.Arrays; @@ -42,7 +43,7 @@ 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, GitlabMergeRequestDecorator.class, DefaultGitlabClientFactory.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 e517c6a3f..8eea72d0b 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 Michael Clarke + * Copyright (C) 2020-2021 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -30,6 +30,8 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Paragraph; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.Text; import org.apache.commons.lang.StringUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; import org.sonar.api.ce.posttask.Analysis; import org.sonar.api.ce.posttask.Project; import org.sonar.api.ce.posttask.QualityGate; @@ -49,6 +51,7 @@ import java.io.UnsupportedEncodingException; import java.math.BigDecimal; +import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.text.DecimalFormat; @@ -124,6 +127,24 @@ public String getIssueUrl(PostAnalysisIssueVisitor.LightIssue issue) { } } + public Optional parseIssueIdFromUrl(String issueUrl) { + URI url = URI.create(issueUrl); + List parameters = URLEncodedUtils.parse(url, StandardCharsets.UTF_8); + if (url.getPath().endsWith("/dashboard")) { + return Optional.of("decorator-summary-comment"); + } else if (url.getPath().endsWith("security_hotspots")) { + return parameters.stream() + .filter(parameter -> "hotspots".equals(parameter.getName())) + .map(NameValuePair::getValue) + .findFirst(); + } else { + return parameters.stream() + .filter(parameter -> "issues".equals(parameter.getName())) + .map(NameValuePair::getValue) + .findFirst(); + } + } + public Optional getPullRequestBase() { return Optional.ofNullable(scannerContext.getProperties().get(SCANNERROPERTY_PULLREQUEST_BASE)); } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReader.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReader.java similarity index 94% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReader.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReader.java index b53f5dfbb..43ae275b9 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReader.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReader.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 Michael Clarke + * Copyright (C) 2020-2021 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -16,7 +16,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest; import java.util.Arrays; import java.util.Optional; diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/LinkHeaderReader.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/LinkHeaderReader.java similarity index 85% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/LinkHeaderReader.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/LinkHeaderReader.java index 8638977e8..9fad2d680 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/LinkHeaderReader.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/LinkHeaderReader.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 Michael Clarke + * Copyright (C) 2020-2021 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -16,11 +16,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest; import java.util.Optional; -interface LinkHeaderReader { +public interface LinkHeaderReader { Optional findNextLink(String linkHeader); diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java index 5d93a7384..6f9717a90 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.LinkHeaderReader; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.GithubApplicationAuthenticationProvider; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.RepositoryAuthenticationToken; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.model.AppInstallation; diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/DefaultGitlabClientFactory.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/DefaultGitlabClientFactory.java new file mode 100644 index 000000000..dac501e73 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/DefaultGitlabClientFactory.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.DefaultLinkHeaderReader; + +public class DefaultGitlabClientFactory implements GitlabClientFactory { + + private final ObjectMapper objectMapper; + + public DefaultGitlabClientFactory() { + super(); + this.objectMapper = new ObjectMapper() + .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + @Override + public GitlabClient createClient(String baseApiUrl, String authToken) { + return new GitlabRestClient(baseApiUrl, authToken, new DefaultLinkHeaderReader(), objectMapper); + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClient.java new file mode 100644 index 000000000..aeeb54e10 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClient.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Commit; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Discussion; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequest; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequestNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.PipelineStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.User; + +import java.io.IOException; +import java.util.List; + +public interface GitlabClient { + + User getCurrentUser() throws IOException; + + MergeRequest getMergeRequest(String projectId, long mergeRequestIid) throws IOException; + + List getMergeRequestCommits(long projectId, long mergeRequestIid) throws IOException; + + List getMergeRequestDiscussions(long projectId, long mergeRequestIid) throws IOException; + + Discussion addMergeRequestDiscussion(long projectId, long mergeRequestIid, MergeRequestNote commitNote) throws IOException; + + void addMergeRequestDiscussionNote(long projectId, long mergeRequestIid, String discussionId, String noteContent) throws IOException; + + void resolveMergeRequestDiscussion(long projectId, long mergeRequestIid, String discussionId) throws IOException; + + void setMergeRequestPipelineStatus(long projectId, String commitRevision, PipelineStatus status) throws IOException; + +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClientFactory.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClientFactory.java new file mode 100644 index 000000000..ab52c893e --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabClientFactory.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +public interface GitlabClientFactory { + + GitlabClient createClient(String baseApiUrl, String authToken); +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecorator.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecorator.java new file mode 100644 index 000000000..a708e814b --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecorator.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2020-2021 Markus Heberling, Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +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.gitlab.model.Commit; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.CommitNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Discussion; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequest; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequestNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Note; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.PipelineStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.User; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.FormatterFactory; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.MarkdownFormatterFactory; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.ImmutableTriple; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; +import org.sonar.api.ce.posttask.QualityGate; +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.ce.task.projectanalysis.scm.Changeset; +import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepository; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class GitlabMergeRequestDecorator implements PullRequestBuildStatusDecorator { + + public static final String PULLREQUEST_GITLAB_INSTANCE_URL = "sonar.pullrequest.gitlab.instanceUrl"; + public static final String PULLREQUEST_GITLAB_PROJECT_ID = "sonar.pullrequest.gitlab.projectId"; + public static final String PULLREQUEST_GITLAB_PROJECT_URL = "sonar.pullrequest.gitlab.projectUrl"; + public static final String PULLREQUEST_GITLAB_PIPELINE_ID = + "com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId"; + + private static final Logger LOGGER = Loggers.get(GitlabMergeRequestDecorator.class); + + private static final String RESOLVED_ISSUE_NEEDING_CLOSED_MESSAGE = + "This issue no longer exists in SonarQube, but due to other comments being present in this discussion, the discussion is not being being closed automatically. " + + "Please manually resolve this discussion once the other comments have been reviewed."; + + private static final List OPEN_ISSUE_STATUSES = + Issue.STATUSES.stream().filter(s -> !Issue.STATUS_CLOSED.equals(s) && !Issue.STATUS_RESOLVED.equals(s)) + .collect(Collectors.toList()); + + private static final Pattern NOTE_VIEW_LINK_PATTERN = Pattern.compile("^\\[View in SonarQube]\\((.*?)\\)$"); + + private final Server server; + private final ScmInfoRepository scmInfoRepository; + private final GitlabClientFactory gitlabClientFactory; + + public GitlabMergeRequestDecorator(Server server, ScmInfoRepository scmInfoRepository, GitlabClientFactory gitlabClientFactory) { + super(); + this.server = server; + this.scmInfoRepository = scmInfoRepository; + this.gitlabClientFactory = gitlabClientFactory; + } + + @Override + public DecorationResult decorateQualityGateStatus(AnalysisDetails analysis, AlmSettingDto almSettingDto, + ProjectAlmSettingDto projectAlmSettingDto) { + String apiURL = Optional.ofNullable(StringUtils.stripToNull(almSettingDto.getUrl())) + .orElseGet(() -> getMandatoryScannerProperty(analysis, PULLREQUEST_GITLAB_INSTANCE_URL)); + String projectId = Optional.ofNullable(StringUtils.stripToNull(projectAlmSettingDto.getAlmRepo())) + .orElseGet(() -> getMandatoryScannerProperty(analysis, PULLREQUEST_GITLAB_PROJECT_ID)); + long mergeRequestIid; + try { + mergeRequestIid = Long.parseLong(analysis.getBranchName()); + } catch (NumberFormatException ex) { + throw new IllegalStateException("Could not parse Merge Request ID", ex); + } + String apiToken = almSettingDto.getPersonalAccessToken(); + + GitlabClient gitlabClient = gitlabClientFactory.createClient(apiURL, apiToken); + + MergeRequest mergeRequest = getMergeRequest(gitlabClient, projectId, mergeRequestIid); + User user = getCurrentGitlabUser(gitlabClient); + List commitIds = getCommitIdsForMergeRequest(gitlabClient, mergeRequest); + List openSonarqubeIssues = analysis.getPostAnalysisIssueVisitor().getIssues().stream() + .filter(i -> OPEN_ISSUE_STATUSES.contains(i.getIssue().getStatus())) + .collect(Collectors.toList()); + + List>> currentProjectSonarqueComments = findOpenSonarqubeComments(gitlabClient, + mergeRequest, + user.getUsername(), + analysis); + + List commentKeysForOpenComments = closeOldDiscussionsAndExtractRemainingKeys(gitlabClient, + user.getUsername(), + currentProjectSonarqueComments, + openSonarqubeIssues, + mergeRequest); + + List> uncommentedIssues = findIssuesWithoutComments(openSonarqubeIssues, + commentKeysForOpenComments) + .stream() + .map(issue -> loadScmPathsForIssues(issue, analysis)) + .filter(Optional::isPresent) + .map(Optional::get) + .filter(issue -> isIssueFromCommitInCurrentMergeRequest(issue.getLeft(), commitIds, scmInfoRepository)) + .collect(Collectors.toList()); + + FormatterFactory formatterFactory = new MarkdownFormatterFactory(); + + uncommentedIssues.forEach(issue -> submitCommitCommentForIssue(issue.getLeft(), + issue.getRight(), + gitlabClient, + mergeRequest, + analysis, + formatterFactory)); + submitSummaryComment(gitlabClient, analysis, mergeRequest, formatterFactory); + submitPipelineStatus(gitlabClient, analysis, mergeRequest, server.getPublicRootUrl()); + + String mergeRequestHtmlUrl = analysis.getScannerProperty(PULLREQUEST_GITLAB_PROJECT_URL) + .map(url -> String.format("%s/merge_requests/%s", url, mergeRequestIid)) + .orElse(mergeRequest.getWebUrl()); + + return DecorationResult.builder().withPullRequestUrl(mergeRequestHtmlUrl).build(); + } + + @Override + public List alm() { + return Collections.singletonList(ALM.GITLAB); + } + + private static MergeRequest getMergeRequest(GitlabClient gitlabClient, String projectId, long mergeRequestIid) { + try { + return gitlabClient.getMergeRequest(projectId, mergeRequestIid); + } catch (IOException ex) { + throw new IllegalStateException("Could not retrieve Merge Request details", ex); + } + } + + private static User getCurrentGitlabUser(GitlabClient gitlabClient) { + try { + return gitlabClient.getCurrentUser(); + } catch (IOException ex) { + throw new IllegalStateException("Could not retrieve current user details", ex); + } + } + + private static List getCommitIdsForMergeRequest(GitlabClient gitlabClient, MergeRequest mergeRequest) { + try { + return gitlabClient.getMergeRequestCommits(mergeRequest.getSourceProjectId(), mergeRequest.getIid()).stream() + .map(Commit::getId) + .collect(Collectors.toList()); + } catch (IOException ex) { + throw new IllegalStateException("Could not retrieve commit details for Merge Request", ex); + } + } + + private static void submitPipelineStatus(GitlabClient gitlabClient, AnalysisDetails analysis, MergeRequest mergeRequest, String sonarqubeRootUrl) { + try { + Long pipelineId = analysis.getScannerProperty(PULLREQUEST_GITLAB_PIPELINE_ID) + .map(Long::parseLong) + .orElse(null); + + BigDecimal coverage = analysis.getCoverage().orElse(null); + + String dashboardUrl = String.format( + "%s/dashboard?id=%s&pullRequest=%s", + sonarqubeRootUrl, + URLEncoder.encode(analysis.getAnalysisProjectKey(), StandardCharsets.UTF_8.name()), + URLEncoder.encode(analysis.getBranchName(), StandardCharsets.UTF_8.name())); + + PipelineStatus pipelineStatus = new PipelineStatus("SonarQube", + "SonarQube Status", + analysis.getQualityGateStatus() == QualityGate.Status.OK ? PipelineStatus.State.SUCCESS : PipelineStatus.State.FAILED, + dashboardUrl, + coverage, + pipelineId); + + gitlabClient.setMergeRequestPipelineStatus(mergeRequest.getSourceProjectId(), analysis.getCommitSha(), pipelineStatus); + } catch (IOException ex) { + throw new IllegalStateException("Could not update pipeline status in Gitlab", ex); + } + } + + private static String getMandatoryScannerProperty(AnalysisDetails analysis, String propertyName) { + return analysis.getScannerProperty(propertyName) + .orElseThrow(() -> new IllegalStateException(String.format("'%s' has not been set in scanner properties", propertyName))); + } + + private static List findIssuesWithoutComments(List openSonarqubeIssues, + List openGitlabIssueIdentifiers) { + return openSonarqubeIssues.stream() + .filter(issue -> !openGitlabIssueIdentifiers.contains(issue.getIssue().key())) + .filter(issue -> issue.getIssue().getLine() != null) + .collect(Collectors.toList()); + } + + private static Optional> loadScmPathsForIssues(PostAnalysisIssueVisitor.ComponentIssue componentIssue, + AnalysisDetails analysis) { + return Optional.of(componentIssue) + .map(issue -> new ImmutablePair<>(issue, analysis.getSCMPathForIssue(issue))) + .filter(pair -> pair.getRight().isPresent()) + .map(pair -> new ImmutablePair<>(pair.getLeft(), pair.getRight().get())); + } + + private static void submitCommitCommentForIssue(PostAnalysisIssueVisitor.ComponentIssue issue, String path, + GitlabClient gitlabClient, MergeRequest mergeRequest, + AnalysisDetails analysis, FormatterFactory formatterFactory) { + String issueSummary = analysis.createAnalysisIssueSummary(issue, formatterFactory); + + Integer line = Optional.ofNullable(issue.getIssue().getLine()).orElseThrow(() -> new IllegalStateException("No line is associated with this issue")); + + try { + gitlabClient.addMergeRequestDiscussion(mergeRequest.getSourceProjectId(), mergeRequest.getIid(), new CommitNote(issueSummary, + mergeRequest.getDiffRefs().getBaseSha(), + mergeRequest.getDiffRefs().getStartSha(), + mergeRequest.getDiffRefs().getHeadSha(), + path, + path, + line)); + } catch (IOException ex) { + throw new IllegalStateException("Could not submit commit comment to Gitlab", ex); + } + } + + private static boolean isIssueFromCommitInCurrentMergeRequest(PostAnalysisIssueVisitor.ComponentIssue componentIssue, List commitIds, ScmInfoRepository scmInfoRepository) { + return Optional.of(componentIssue) + .map(issue -> new ImmutablePair<>(issue.getIssue(), scmInfoRepository.getScmInfo(issue.getComponent()))) + .filter(issuePair -> issuePair.getRight().isPresent()) + .map(issuePair -> new ImmutablePair<>(issuePair.getLeft(), issuePair.getRight().get())) + .filter(issuePair -> null != issuePair.getLeft().getLine()) + .filter(issuePair -> issuePair.getRight().hasChangesetForLine(issuePair.getLeft().getLine())) + .map(issuePair -> issuePair.getRight().getChangesetForLine(issuePair.getLeft().getLine())) + .map(Changeset::getRevision) + .filter(commitIds::contains) + .isPresent(); + } + + private static void submitSummaryComment(GitlabClient gitlabClient, AnalysisDetails analysis, MergeRequest mergeRequest, + FormatterFactory formatterFactory) { + try { + String summaryCommentBody = analysis.createAnalysisSummary(formatterFactory); + Discussion summaryComment = gitlabClient.addMergeRequestDiscussion(mergeRequest.getSourceProjectId(), + mergeRequest.getIid(), + new MergeRequestNote(summaryCommentBody)); + if (analysis.getQualityGateStatus() == QualityGate.Status.OK) { + gitlabClient.resolveMergeRequestDiscussion(mergeRequest.getSourceProjectId(), mergeRequest.getIid(), summaryComment.getId()); + } + } catch (IOException ex) { + throw new IllegalStateException("Could not submit summary comment to Gitlab", ex); + } + + } + + private static List>> findOpenSonarqubeComments(GitlabClient gitlabClient, + MergeRequest mergeRequest, + String currentUsername, + AnalysisDetails analysisDetails) { + try { + return gitlabClient.getMergeRequestDiscussions(mergeRequest.getSourceProjectId(), mergeRequest.getIid()).stream() + .map(discussion -> discussion.getNotes().stream() + .findFirst() + .filter(note -> currentUsername.equals(note.getAuthor().getUsername())) + .filter(note -> !isResolved(discussion, note, currentUsername)) + .filter(Note::isResolvable) + .map(note -> new ImmutableTriple<>(discussion, note, parseIssueDetails(note, analysisDetails)))) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } catch (IOException ex) { + throw new IllegalStateException("Could not retrieve Merge Request discussions", ex); + } + } + + private static List closeOldDiscussionsAndExtractRemainingKeys(GitlabClient gitlabClient, String currentUsername, + List>> openSonarqubeComments, + List openIssues, + MergeRequest mergeRequest) { + List openIssueKeys = openIssues.stream() + .map(issue -> issue.getIssue().key()) + .collect(Collectors.toList()); + + List remainingCommentKeys = new ArrayList<>(); + + for (Triple> openSonarqubeComment : openSonarqubeComments) { + Optional issueKey = openSonarqubeComment.getRight(); + Discussion discussion = openSonarqubeComment.getLeft(); + if (!issueKey.isPresent()) { + LOGGER.warn("Note {} was found on discussion {} in Merge Request {} posted by Sonarqube user {}, " + + "but Sonarqube issue details could not be parsed from it. " + + "This discussion will therefore will not be cleaned up.", + openSonarqubeComment.getMiddle().getId(), + openSonarqubeComment.getLeft().getId(), + mergeRequest.getIid(), + currentUsername); + } else if (!openIssueKeys.contains(issueKey.get())) { + resolveOrPlaceFinalCommentOnDiscussion(gitlabClient, currentUsername, discussion, mergeRequest); + } else { + remainingCommentKeys.add(issueKey.get()); + } + } + + return remainingCommentKeys; + } + + private static boolean isResolved(Discussion discussion, Note note, String currentUsername) { + return note.isResolved() || discussion.getNotes().stream() + .filter(message -> currentUsername.equals(note.getAuthor().getUsername())) + .anyMatch(message -> RESOLVED_ISSUE_NEEDING_CLOSED_MESSAGE.equals(message.getBody())); + } + + private static void resolveOrPlaceFinalCommentOnDiscussion(GitlabClient gitlabClient, String currentUsername, + Discussion discussion, MergeRequest mergeRequest) { + try { + if (discussion.getNotes().stream() + .filter(note -> !note.isSystem()) + .anyMatch(note -> !currentUsername.equals(note.getAuthor().getUsername()))) { + gitlabClient.addMergeRequestDiscussionNote(mergeRequest.getSourceProjectId(), mergeRequest.getIid(), discussion.getId(), RESOLVED_ISSUE_NEEDING_CLOSED_MESSAGE); + } else { + gitlabClient.resolveMergeRequestDiscussion(mergeRequest.getSourceProjectId(), mergeRequest.getIid(), discussion.getId()); + } + } catch (IOException ex) { + throw new IllegalStateException("Could not clean-up discussion on Gitlab", ex); + } + } + + private static Optional parseIssueDetails(Note note, AnalysisDetails analysisDetails) { + try (BufferedReader reader = new BufferedReader(new StringReader(note.getBody()))) { + return reader.lines() + .filter(line -> line.contains("View in SonarQube")) + .map(line -> parseIssueLineDetails(line, analysisDetails)) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + } catch (IOException ex) { + throw new IllegalStateException("Could not parse details from note", ex); + } + } + + private static Optional parseIssueLineDetails(String noteLine, AnalysisDetails analysisDetails) { + Matcher identifierMatcher = NOTE_VIEW_LINK_PATTERN.matcher(noteLine); + + if (identifierMatcher.matches()) { + return analysisDetails.parseIssueIdFromUrl(identifierMatcher.group(1)); + } else { + return Optional.empty(); + } + } + +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabRestClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabRestClient.java new file mode 100644 index 000000000..6bd2e38c1 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabRestClient.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.LinkHeaderReader; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Commit; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.CommitNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Discussion; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequest; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequestNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.PipelineStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.User; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.ContentType; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +class GitlabRestClient implements GitlabClient { + + private static final Logger LOGGER = Loggers.get(GitlabRestClient.class); + + private final String baseGitlabApiUrl; + private final String authToken; + private final ObjectMapper objectMapper; + private final LinkHeaderReader linkHeaderReader; + + GitlabRestClient(String baseGitlabApiUrl, String authToken, LinkHeaderReader linkHeaderReader, ObjectMapper objectMapper) { + this.baseGitlabApiUrl = baseGitlabApiUrl; + this.authToken = authToken; + this.linkHeaderReader = linkHeaderReader; + this.objectMapper = objectMapper; + } + + @Override + public MergeRequest getMergeRequest(String projectSlug, long mergeRequestIid) throws IOException { + return entity(new HttpGet(String.format("%s/projects/%s/merge_requests/%s", baseGitlabApiUrl, URLEncoder.encode(projectSlug, StandardCharsets.UTF_8.name()), mergeRequestIid)), MergeRequest.class); + } + + @Override + public User getCurrentUser() throws IOException { + return entity(new HttpGet(String.format("%s/user", baseGitlabApiUrl)), User.class); + } + + @Override + public List getMergeRequestCommits(long projectId, long mergeRequestIid) throws IOException { + return entities(new HttpGet(String.format("%s/projects/%s/merge_requests/%s/commits", baseGitlabApiUrl, projectId, mergeRequestIid)), Commit.class); + } + + @Override + public List getMergeRequestDiscussions(long projectId, long mergeRequestIid) throws IOException { + return entities(new HttpGet(String.format("%s/projects/%s/merge_requests/%s/discussions", baseGitlabApiUrl, projectId, mergeRequestIid)), Discussion.class); + } + + @Override + public Discussion addMergeRequestDiscussion(long projectId, long mergeRequestIid, MergeRequestNote mergeRequestNote) throws IOException { + String targetUrl = String.format("%s/projects/%s/merge_requests/%s/discussions", baseGitlabApiUrl, projectId, mergeRequestIid); + + List requestContent = new ArrayList<>(); + requestContent.add(new BasicNameValuePair("body", mergeRequestNote.getContent())); + + if (mergeRequestNote instanceof CommitNote) { + CommitNote commitNote = (CommitNote) mergeRequestNote; + requestContent.addAll(Arrays.asList( + new BasicNameValuePair("position[base_sha]", commitNote.getBaseSha()), + new BasicNameValuePair("position[start_sha]", commitNote.getStartSha()), + new BasicNameValuePair("position[head_sha]", commitNote.getHeadSha()), + new BasicNameValuePair("position[old_path]", commitNote.getOldPath()), + new BasicNameValuePair("position[new_path]", commitNote.getNewPath()), + new BasicNameValuePair("position[new_line]", String.valueOf(commitNote.getNewLine())), + new BasicNameValuePair("position[position_type]", "text")) + ); + } + + HttpPost httpPost = new HttpPost(targetUrl); + httpPost.addHeader("Content-type", ContentType.APPLICATION_FORM_URLENCODED.getMimeType()); + httpPost.setEntity(new UrlEncodedFormEntity(requestContent)); + return entity(httpPost, Discussion.class, httpResponse -> validateResponse(httpResponse, 201, "Discussion successfully created")); + } + + @Override + public void addMergeRequestDiscussionNote(long projectId, long mergeRequestIid, String discussionId, String noteContent) throws IOException { + String targetUrl = String.format("%s/projects/%s/merge_requests/%s/discussions/%s/notes", baseGitlabApiUrl, projectId, mergeRequestIid, discussionId); + + HttpPost httpPost = new HttpPost(targetUrl); + httpPost.setEntity(new UrlEncodedFormEntity(Collections.singletonList(new BasicNameValuePair("body", noteContent)))); + entity(httpPost, null, httpResponse -> validateResponse(httpResponse, 201, "Commit discussions note added")); + } + + @Override + public void resolveMergeRequestDiscussion(long projectId, long mergeRequestIid, String discussionId) throws IOException { + String discussionIdUrl = String.format("%s/projects/%s/merge_requests/%s/discussions/%s?resolved=true", baseGitlabApiUrl, projectId, mergeRequestIid, discussionId); + + HttpPut httpPut = new HttpPut(discussionIdUrl); + entity(httpPut, null); + } + + @Override + public void setMergeRequestPipelineStatus(long projectId, String commitRevision, PipelineStatus status) throws IOException { + List entityFields = new ArrayList<>(Arrays.asList( + new BasicNameValuePair("name", status.getPipelineName()), + new BasicNameValuePair("target_url", status.getTargetUrl()), + new BasicNameValuePair("description", status.getPipelineDescription()))); + + status.getPipelineId().ifPresent(pipelineId -> entityFields.add(new BasicNameValuePair("pipeline_id", Long.toString(pipelineId)))); + status.getCoverage().ifPresent(coverage -> entityFields.add(new BasicNameValuePair("coverage", coverage.toString()))); + + String statusUrl = String.format("%s/projects/%s/statuses/%s?state=%s", baseGitlabApiUrl, projectId, commitRevision, status.getState().getLabel()); + + HttpPost httpPost = new HttpPost(statusUrl); + httpPost.addHeader("Content-type", ContentType.APPLICATION_FORM_URLENCODED.getMimeType()); + httpPost.setEntity(new UrlEncodedFormEntity(entityFields)); + entity(httpPost, null, httpResponse -> { + if (httpResponse.toString().contains("Cannot transition status")) { + // Workaround for https://gitlab.com/gitlab-org/gitlab-ce/issues/25807 + LOGGER.debug("Transition status is already {}", status); + } else { + validateResponse(httpResponse, 201, "Comment posted"); + } + }); + } + + private X entity(HttpRequestBase httpRequest, Class type) throws IOException { + return entity(httpRequest, type, httpResponse -> validateResponse(httpResponse, 200, null)); + } + + private X entity(HttpRequestBase httpRequest, Class type, Consumer responseValidator) throws IOException { + httpRequest.addHeader("PRIVATE-TOKEN", authToken); + + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + HttpResponse httpResponse = httpClient.execute(httpRequest); + + responseValidator.accept(httpResponse); + + if (null == type) { + return null; + } + return objectMapper.readValue(EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8), type); + } + } + + private List entities(HttpGet httpRequest, Class type) throws IOException { + return entities(httpRequest, type, httpResponse -> validateResponse(httpResponse, 200, null)); + } + + private List entities(HttpGet httpRequest, Class type, Consumer responseValidator) throws IOException { + httpRequest.addHeader("PRIVATE-TOKEN", authToken); + + try (CloseableHttpClient httpClient = HttpClients.createSystem()) { + HttpResponse httpResponse = httpClient.execute(httpRequest); + + responseValidator.accept(httpResponse); + + List entities = new ArrayList<>(objectMapper.readValue(EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8), + objectMapper.getTypeFactory().constructCollectionType(List.class, type))); + + Optional nextURL = Optional.ofNullable(httpResponse.getFirstHeader("Link")) + .map(NameValuePair::getValue) + .flatMap(linkHeaderReader::findNextLink); + if (nextURL.isPresent()) { + entities.addAll(entities(new HttpGet(nextURL.get()), type, responseValidator)); + } + + return entities; + } + } + + private static void validateResponse(HttpResponse httpResponse, int expectedStatus, String successLogMessage) { + if (httpResponse.getStatusLine().getStatusCode() == expectedStatus) { + LOGGER.debug(Optional.ofNullable(successLogMessage).map(v -> v + System.lineSeparator()).orElse("") + httpResponse.toString()); + return; + } + + String responseContent; + try { + responseContent = EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8); + } catch (IOException ex) { + LOGGER.warn("Could not decode response entity", ex); + responseContent = ""; + } + + LOGGER.error("Gitlab response status did not match expected value. Expected: " + expectedStatus + + System.lineSeparator() + + httpResponse.toString() + + System.lineSeparator() + + responseContent); + + throw new IllegalStateException("An unexpected response code was returned from the Gitlab API - Expected: " + expectedStatus + ", Got: " + httpResponse.toString()); + + } + +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecorator.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecorator.java deleted file mode 100644 index 7d08b028c..000000000 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecorator.java +++ /dev/null @@ -1,408 +0,0 @@ -/* - * Copyright (C) 2020-2021 Markus Heberling, Michael Clarke - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -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.gitlab.response.Commit; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response.Discussion; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response.MergeRequest; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response.Note; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response.User; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.MarkdownFormatterFactory; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringUtils; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.HttpDelete; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicNameValuePair; -import org.apache.http.util.EntityUtils; -import org.sonar.api.ce.posttask.QualityGate; -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.ce.task.projectanalysis.scm.Changeset; -import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepository; -import org.sonar.db.alm.setting.ALM; -import org.sonar.db.alm.setting.AlmSettingDto; -import org.sonar.db.alm.setting.ProjectAlmSettingDto; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -public class GitlabServerPullRequestDecorator implements PullRequestBuildStatusDecorator { - - public static final String PULLREQUEST_GITLAB_INSTANCE_URL = - "sonar.pullrequest.gitlab.instanceUrl"; - public static final String PULLREQUEST_GITLAB_PROJECT_ID = - "sonar.pullrequest.gitlab.projectId"; - public static final String PULLREQUEST_GITLAB_PROJECT_URL = - "sonar.pullrequest.gitlab.projectUrl"; - public static final String PULLREQUEST_GITLAB_PIPELINE_ID = - "com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId"; - - private static final Logger LOGGER = Loggers.get(GitlabServerPullRequestDecorator.class); - private static final List OPEN_ISSUE_STATUSES = - Issue.STATUSES.stream().filter(s -> !Issue.STATUS_CLOSED.equals(s) && !Issue.STATUS_RESOLVED.equals(s)) - .collect(Collectors.toList()); - - private final Server server; - private final ScmInfoRepository scmInfoRepository; - - public GitlabServerPullRequestDecorator(Server server, ScmInfoRepository scmInfoRepository) { - super(); - this.server = server; - this.scmInfoRepository = scmInfoRepository; - } - - @Override - public DecorationResult decorateQualityGateStatus(AnalysisDetails analysis, AlmSettingDto almSettingDto, - ProjectAlmSettingDto projectAlmSettingDto) { - LOGGER.info("starting to analyze with " + analysis.toString()); - String revision = analysis.getCommitSha(); - - try { - final String apiURL = Optional.ofNullable(StringUtils.stripToNull(almSettingDto.getUrl())) - .orElseGet(() -> analysis.getScannerProperty(PULLREQUEST_GITLAB_INSTANCE_URL) - .orElseThrow(() -> new IllegalStateException(String.format( - "Could not decorate Gitlab merge request. '%s' has not been set in scanner properties", - PULLREQUEST_GITLAB_INSTANCE_URL)))); - final String apiToken = almSettingDto.getPersonalAccessToken(); - final String projectId = Optional.ofNullable(StringUtils.stripToNull(projectAlmSettingDto.getAlmRepo())) - .orElseGet(() -> analysis.getScannerProperty(PULLREQUEST_GITLAB_PROJECT_ID) - .orElseThrow(() -> new IllegalStateException(String.format( - "Could not decorate Gitlab merge request. '%s' has not been set in scanner properties", - PULLREQUEST_GITLAB_PROJECT_ID)))); - final String pullRequestId = analysis.getBranchName(); - - final String projectURL = apiURL + String.format("/projects/%s", URLEncoder - .encode(projectId, StandardCharsets.UTF_8.name())); - final String userURL = apiURL + "/user"; - final String mergeRequestURl = projectURL + String.format("/merge_requests/%s", pullRequestId); - - Map headers = new HashMap<>(); - headers.put("PRIVATE-TOKEN", apiToken); - headers.put("Accept", "application/json"); - - MergeRequest mergeRequest = getSingle(mergeRequestURl, headers, MergeRequest.class); - - final String sourceProjectURL = apiURL + String.format("/projects/%s", URLEncoder.encode(mergeRequest.getSourceProjectId(), StandardCharsets.UTF_8.name())); - final String statusUrl = sourceProjectURL + String.format("/statuses/%s", revision); - - final String prCommitsURL = mergeRequestURl + "/commits"; - final String mergeRequestDiscussionURL = mergeRequestURl + "/discussions"; - - final String prHtmlUrl = analysis.getScannerProperty(PULLREQUEST_GITLAB_PROJECT_URL).map(url -> String.format("%s/merge_requests/%s", url, pullRequestId)).orElse(null); - - LOGGER.info(String.format("Status url is: %s ", statusUrl)); - LOGGER.info(String.format("PR commits url is: %s ", prCommitsURL)); - LOGGER.info(String.format("MR discussion url is: %s ", mergeRequestDiscussionURL)); - LOGGER.info(String.format("User url is: %s ", userURL)); - - User user = getSingle(userURL, headers, User.class); - LOGGER.info(String.format("Using user: %s ", user.getUsername())); - - List commits = getPagedList(prCommitsURL, headers, new TypeReference>() { - }).stream().map(Commit::getId).collect(Collectors.toList()); - - List discussions = getPagedList(mergeRequestDiscussionURL, headers, new TypeReference>() { - }); - - LOGGER.info(String.format("Discussions in MR: %s ", discussions - .stream() - .map(Discussion::getId) - .collect(Collectors.joining(", ")))); - - for (Discussion discussion : discussions) { - for (Note note : discussion.getNotes()) { - if (!note.isSystem() && note.getAuthor() != null && note.getAuthor().getUsername().equals(user.getUsername())) { - //delete only our own comments - deleteCommitDiscussionNote(mergeRequestDiscussionURL + String.format("/%s/notes/%s", - discussion.getId(), - note.getId()), - headers); - } - } - } - - List openIssues = analysis.getPostAnalysisIssueVisitor().getIssues().stream().filter(i -> OPEN_ISSUE_STATUSES.contains(i.getIssue().getStatus())).collect(Collectors.toList()); - - String summaryCommentBody = analysis.createAnalysisSummary(new MarkdownFormatterFactory()); - List summaryContentParams = Collections - .singletonList(new BasicNameValuePair("body", summaryCommentBody)); - - BigDecimal coverageValue = analysis.getCoverage().orElse(null); - - postStatus(new StringBuilder(statusUrl), headers, analysis, coverageValue); - - Discussion summaryComment = postCommitComment(mergeRequestDiscussionURL, headers, summaryContentParams); - if (null != summaryComment && analysis.getQualityGateStatus() == QualityGate.Status.OK) { - resolveCommitComment(mergeRequestDiscussionURL, headers, summaryComment); - } - - - for (PostAnalysisIssueVisitor.ComponentIssue issue : openIssues) { - String path = analysis.getSCMPathForIssue(issue).orElse(null); - if (path != null && issue.getIssue().getLine() != null) { - //only if we have a path and line number - String fileComment = analysis.createAnalysisIssueSummary(issue, new MarkdownFormatterFactory()); - - if (scmInfoRepository.getScmInfo(issue.getComponent()) - .filter(i -> i.hasChangesetForLine(issue.getIssue().getLine())) - .map(i -> i.getChangesetForLine(issue.getIssue().getLine())) - .map(Changeset::getRevision) - .filter(commits::contains) - .isPresent()) { - //only if the change is on a commit, that belongs to this MR - - List fileContentParams = Arrays.asList( - new BasicNameValuePair("body", fileComment), - new BasicNameValuePair("position[base_sha]", mergeRequest.getDiffRefs().getBaseSha()), - new BasicNameValuePair("position[start_sha]", mergeRequest.getDiffRefs().getStartSha()), - new BasicNameValuePair("position[head_sha]", mergeRequest.getDiffRefs().getHeadSha()), - new BasicNameValuePair("position[old_path]", path), - new BasicNameValuePair("position[new_path]", path), - new BasicNameValuePair("position[new_line]", String.valueOf(issue.getIssue().getLine())), - new BasicNameValuePair("position[position_type]", "text")); - - postCommitComment(mergeRequestDiscussionURL, headers, fileContentParams); - } else { - LOGGER.info(String.format("Skipping %s:%d since the commit does not belong to the MR", path, issue.getIssue().getLine())); - } - } - } - - return DecorationResult.builder().withPullRequestUrl(prHtmlUrl).build(); - } catch (IOException ex) { - throw new IllegalStateException("Could not decorate Pull Request on Gitlab Server", ex); - } - - } - - @Override - public List alm() { - return Collections.singletonList(ALM.GITLAB); - } - - private X getSingle(String userURL, Map headers, Class type) throws IOException { - HttpGet httpGet = new HttpGet(userURL); - for (Map.Entry entry : headers.entrySet()) { - httpGet.addHeader(entry.getKey(), entry.getValue()); - } - 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( - "An error was returned in the response from the Gitlab API. See the previous log messages for details"); - } else if (null != httpResponse) { - LOGGER.debug(httpResponse.toString()); - HttpEntity entity = httpResponse.getEntity(); - X user = new ObjectMapper() - .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 user; - } else { - throw new IOException("No response reveived"); - } - } - } - - private List getPagedList(String commitDiscussionURL, Map headers, - TypeReference> typeRef) throws IOException { - HttpGet httpGet = new HttpGet(commitDiscussionURL); - for (Map.Entry entry : headers.entrySet()) { - httpGet.addHeader(entry.getKey(), entry.getValue()); - } - - List discussions = new ArrayList<>(); - - 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("An error was returned in the response from the Gitlab API. See the previous log messages for details"); - } else if (null != httpResponse) { - LOGGER.debug(httpResponse.toString()); - HttpEntity entity = httpResponse.getEntity(); - List pagedDiscussions = new ObjectMapper() - .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), typeRef); - discussions.addAll(pagedDiscussions); - LOGGER.info("MR discussions received"); - Optional nextURL = getNextUrl(httpResponse); - if (nextURL.isPresent()) { - LOGGER.info("Getting next page"); - discussions.addAll(getPagedList(nextURL.get(), headers, typeRef)); - } - } - } - return discussions; - } - - private void deleteCommitDiscussionNote(String commitDiscussionNoteURL, Map headers) throws IOException { - //https://docs.gitlab.com/ee/api/discussions.html#delete-a-commit-thread-note - HttpDelete httpDelete = new HttpDelete(commitDiscussionNoteURL); - for (Map.Entry entry : headers.entrySet()) { - httpDelete.addHeader(entry.getKey(), entry.getValue()); - } - - try (CloseableHttpClient httpClient = HttpClients.createSystem()) { - LOGGER.info("Deleting {} with headers {}", commitDiscussionNoteURL, headers); - - HttpResponse httpResponse = httpClient.execute(httpDelete); - validateGitlabResponse(httpResponse, 204, "Commit discussions note deleted"); - } - } - - private Discussion postCommitComment(String commitCommentUrl, Map headers, List params) throws IOException { - //https://docs.gitlab.com/ee/api/commits.html#post-comment-to-commit - HttpPost httpPost = new HttpPost(commitCommentUrl); - for (Map.Entry entry : headers.entrySet()) { - httpPost.addHeader(entry.getKey(), entry.getValue()); - } - httpPost.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); - - LOGGER.info("Posting {} with headers {} to {}", params, headers, commitCommentUrl); - - Discussion newComment = null; - - try (CloseableHttpClient httpClient = HttpClients.createSystem()) { - HttpResponse httpResponse = httpClient.execute(httpPost); - validateGitlabResponse(httpResponse, 201, "Comment posted"); - HttpEntity httpEntity = httpResponse.getEntity(); - if (null != httpEntity) { - String json = IOUtils.toString(httpEntity.getContent(), StandardCharsets.UTF_8); - newComment = new ObjectMapper() - .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(json, Discussion.class); - } - } - - return newComment; - } - - private void resolveCommitComment(String commitCommentUrl, Map headers, Discussion comment) throws IOException { - String statusPutUrl = commitCommentUrl + "/" + comment.getId() + "?resolved=true"; - HttpPut httpPut = new HttpPut(statusPutUrl); - for (Map.Entry entry : headers.entrySet()) { - httpPut.addHeader(entry.getKey(), entry.getValue()); - } - LOGGER.info("Resolving discussion {} with headers {} via {}", comment.getId(), headers, commitCommentUrl); - try (CloseableHttpClient httpClient = HttpClients.createSystem()) { - HttpResponse httpResponse = httpClient.execute(httpPut); - validateGitlabResponse(httpResponse, 200, "Comment resolved"); - } - } - - private void postStatus(StringBuilder statusPostUrl, Map headers, AnalysisDetails analysis, - BigDecimal coverage) throws IOException { - //See https://docs.gitlab.com/ee/api/commits.html#post-the-build-status-to-a-commit - statusPostUrl.append("?name=SonarQube"); - String status = (analysis.getQualityGateStatus() == QualityGate.Status.OK ? "success" : "failed"); - statusPostUrl.append("&state=").append(status); - statusPostUrl.append("&target_url=").append(URLEncoder.encode(String.format("%s/dashboard?id=%s&pullRequest=%s", server.getPublicRootUrl(), - URLEncoder.encode(analysis.getAnalysisProjectKey(), - StandardCharsets.UTF_8.name()), URLEncoder - .encode(analysis.getBranchName(), - StandardCharsets.UTF_8.name())), StandardCharsets.UTF_8.name())); - statusPostUrl.append("&description=").append(URLEncoder.encode("SonarQube Status", StandardCharsets.UTF_8.name())); - if (coverage != null) { - statusPostUrl.append("&coverage=").append(coverage.toString()); - } - analysis.getScannerProperty(PULLREQUEST_GITLAB_PIPELINE_ID).ifPresent(pipelineId -> statusPostUrl.append("&pipeline_id=").append(pipelineId)); - - HttpPost httpPost = new HttpPost(statusPostUrl.toString()); - for (Map.Entry entry : headers.entrySet()) { - httpPost.addHeader(entry.getKey(), entry.getValue()); - } - - try (CloseableHttpClient httpClient = HttpClients.createSystem()) { - HttpResponse httpResponse = httpClient.execute(httpPost); - if (null != httpResponse && httpResponse.toString().contains("Cannot transition status")) { - // Workaround for https://gitlab.com/gitlab-org/gitlab-ce/issues/25807 - LOGGER.debug("Transition status is already {}", status); - } else { - validateGitlabResponse(httpResponse, 201, "Comment posted"); - } - } - } - - private void validateGitlabResponse(HttpResponse httpResponse, int expectedStatus, String successLogMessage) throws IOException { - if (null != httpResponse && httpResponse.getStatusLine().getStatusCode() != expectedStatus) { - LOGGER.error(httpResponse.toString()); - LOGGER.error(EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8)); - throw new IllegalStateException("An error was returned in the response from the Gitlab API. See the previous log messages for details"); - } else if (null != httpResponse) { - LOGGER.debug(httpResponse.toString()); - LOGGER.info(successLogMessage); - } - } - - private static Optional getNextUrl(HttpResponse httpResponse) { - Header linkHeader = httpResponse.getFirstHeader("Link"); - if (linkHeader != null) { - Matcher matcher = Pattern.compile("<([^>]+)>;[\\s]*rel=\"([a-z]+)\"").matcher(linkHeader.getValue()); - while (matcher.find()) { - if (matcher.group(2).equals("next")) { - //found the next rel return the URL - return Optional.of(matcher.group(1)); - } - } - } - return Optional.empty(); - } -} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Commit.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Commit.java similarity index 98% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Commit.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Commit.java index 5d54c799d..4c3afc3eb 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Commit.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Commit.java @@ -16,7 +16,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/CommitNote.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/CommitNote.java new file mode 100644 index 000000000..4b9eac8eb --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/CommitNote.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; + +public class CommitNote extends MergeRequestNote { + + private final String baseSha; + private final String startSha; + private final String headSha; + private final String oldPath; + private final String newPath; + private final int newLine; + + public CommitNote(String content, String baseSha, String startSha, String headSha, String oldPath, String newPath, int newLine) { + super(content); + this.baseSha = baseSha; + this.startSha = startSha; + this.headSha = headSha; + this.oldPath = oldPath; + this.newPath = newPath; + this.newLine = newLine; + } + + public String getBaseSha() { + return baseSha; + } + + public String getStartSha() { + return startSha; + } + + public String getHeadSha() { + return headSha; + } + + public String getOldPath() { + return oldPath; + } + + public String getNewPath() { + return newPath; + } + + public int getNewLine() { + return newLine; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/DiffRefs.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/DiffRefs.java new file mode 100644 index 000000000..f9b666639 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/DiffRefs.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DiffRefs { + private final String baseSha; + private final String headSha; + private final String startSha; + + public DiffRefs(@JsonProperty("base_sha") String baseSha, @JsonProperty("head_sha") String headSha, @JsonProperty("start_sha") String startSha) { + this.baseSha = baseSha; + this.headSha = headSha; + this.startSha = startSha; + } + + public String getBaseSha() { + return baseSha; + } + + public String getHeadSha() { + return headSha; + } + + public String getStartSha() { + return startSha; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Discussion.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Discussion.java similarity index 99% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Discussion.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Discussion.java index 17b487f1e..9f06bf3ee 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Discussion.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Discussion.java @@ -16,16 +16,16 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; - -import java.util.List; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + public class Discussion { - private final String id; + private final String id; private final List notes; @JsonCreator diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequest.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequest.java new file mode 100644 index 000000000..530e6d6a1 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequest.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MergeRequest { + private final long iid; + private final DiffRefs diffRefs; + private final long sourceProjectId; + private final String webUrl; + + public MergeRequest(@JsonProperty("iid") long iid, @JsonProperty("diff_refs") DiffRefs diffRefs, + @JsonProperty("source_project_id") long sourceProjectId, + @JsonProperty("web_url") String webUrl) { + this.iid = iid; + this.diffRefs = diffRefs; + this.sourceProjectId = sourceProjectId; + this.webUrl = webUrl; + } + + public long getIid() { + return iid; + } + + public DiffRefs getDiffRefs() { + return diffRefs; + } + + public long getSourceProjectId() { + return sourceProjectId; + } + + public String getWebUrl() { + return webUrl; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequestNote.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequestNote.java new file mode 100644 index 000000000..6ca03952e --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/MergeRequestNote.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; + +public class MergeRequestNote { + + private final String content; + + public MergeRequestNote(String content) { + this.content = content; + } + + public String getContent() { + return content; + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Note.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Note.java similarity index 66% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Note.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Note.java index edb22c9f9..2481c12b5 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/Note.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/Note.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Markus Heberling + * Copyright (C) 2019-2021 Markus Heberling, Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -16,23 +16,30 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; public class Note { - private final long id; + private final long id; private final boolean system; - private final User author; + private final String body; + private final boolean resolved; + private final boolean resolvable; @JsonCreator - public Note(@JsonProperty("id") long id, @JsonProperty("system") boolean system, @JsonProperty("author") User author) { + public Note(@JsonProperty("id") long id, @JsonProperty("system") boolean system, @JsonProperty("author") User author, + @JsonProperty("body") String body, @JsonProperty("resolved") boolean resolved, + @JsonProperty("resolvable") boolean resolvable) { this.id = id; this.system = system; this.author = author; + this.body = body; + this.resolved = resolved; + this.resolvable = resolvable; } public long getId() { @@ -46,4 +53,16 @@ public boolean isSystem() { public User getAuthor() { return author; } + + public String getBody() { + return body; + } + + public boolean isResolved() { + return resolved; + } + + public boolean isResolvable() { + return resolvable; + } } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/PipelineStatus.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/PipelineStatus.java new file mode 100644 index 000000000..3f5f14ce9 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/PipelineStatus.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; + +import java.math.BigDecimal; +import java.util.Optional; + +public class PipelineStatus { + + private final String pipelineName; + private final String pipelineDescription; + private final State state; + private final String targetUrl; + private final BigDecimal coverage; + private final Long pipelineId; + + public PipelineStatus(String pipelineName, String pipelineDescription, State state, String targetUrl, BigDecimal coverage, Long pipelineId) { + this.pipelineName = pipelineName; + this.pipelineDescription = pipelineDescription; + this.state = state; + this.targetUrl = targetUrl; + this.coverage = coverage; + this.pipelineId = pipelineId; + } + + public String getPipelineName() { + return pipelineName; + } + + public String getPipelineDescription() { + return pipelineDescription; + } + + public State getState() { + return state; + } + + public String getTargetUrl() { + return targetUrl; + } + + public Optional getCoverage() { + return Optional.ofNullable(coverage); + } + + public Optional getPipelineId() { + return Optional.ofNullable(pipelineId); + } + + public enum State { + SUCCESS("success"), + FAILED("failed"); + + private final String label; + + State(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/User.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/User.java similarity index 99% rename from src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/User.java rename to src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/User.java index c96d0ebf9..b634e0228 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/User.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/model/User.java @@ -16,7 +16,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/DiffRefs.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/DiffRefs.java deleted file mode 100644 index ac8292023..000000000 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/DiffRefs.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DiffRefs { - private final String baseSha; - private final String headSha; - private final String startSha; - - public DiffRefs(@JsonProperty("base_sha") String baseSha, @JsonProperty("head_sha") String headSha, @JsonProperty("start_sha") String startSha) { - this.baseSha = baseSha; - this.headSha = headSha; - this.startSha = startSha; - } - - public String getBaseSha() { - return baseSha; - } - - public String getHeadSha() { - return headSha; - } - - public String getStartSha() { - return startSha; - } -} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/MergeRequest.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/MergeRequest.java deleted file mode 100644 index 87c53ebe2..000000000 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/response/MergeRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.response; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class MergeRequest { - private final String id; - private final String iid; - private final DiffRefs diffRefs; - private final String sourceProjectId; - - public MergeRequest(@JsonProperty("id") String id, @JsonProperty("iid") String iid, @JsonProperty("diff_refs") DiffRefs diffRefs, @JsonProperty("source_project_id") String sourceProjectId) { - this.id = id; - this.iid = iid; - this.diffRefs = diffRefs; - this.sourceProjectId = sourceProjectId; - } - - public String getId() { - return id; - } - - public String getIid() { - return iid; - } - - public DiffRefs getDiffRefs() { - return diffRefs; - } - - public String getSourceProjectId() { - return sourceProjectId; - } -} 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 235f59b69..1b13e2527 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 @@ -19,7 +19,7 @@ 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.gitlab.GitlabMergeRequestDecorator; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.AzureDevOpsServerPullRequestDecorator; import org.sonar.api.batch.sensor.Sensor; import org.sonar.api.batch.sensor.SensorContext; @@ -49,23 +49,23 @@ public void describe(SensorDescriptor sensorDescriptor) { public void execute(SensorContext sensorContext) { if (Boolean.parseBoolean(system2.envVariable("GITLAB_CI"))) { Optional.ofNullable(system2.envVariable("CI_API_V4_URL")).ifPresent(v -> sensorContext - .addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL, v)); + .addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL, v)); Optional.ofNullable(system2.envVariable("CI_PROJECT_PATH")).ifPresent(v -> sensorContext - .addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID, v)); + .addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID, v)); Optional.ofNullable(system2.envVariable("CI_MERGE_REQUEST_PROJECT_URL")).ifPresent(v -> sensorContext - .addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL, v)); + .addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL, v)); Optional.ofNullable(system2.envVariable("CI_PIPELINE_ID")).ifPresent(v -> sensorContext - .addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID, v)); + .addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID, v)); } - Optional.ofNullable(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL)).ifPresent( - v -> sensorContext.addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL, v)); - Optional.ofNullable(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID)).ifPresent( - v -> sensorContext.addContextProperty(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID, v)); - Optional.ofNullable(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL)).ifPresent( - 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)); + Optional.ofNullable(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL)).ifPresent( + v -> sensorContext.addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL, v)); + Optional.ofNullable(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID)).ifPresent( + v -> sensorContext.addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID, v)); + Optional.ofNullable(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL)).ifPresent( + v -> sensorContext.addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL, v)); + Optional.ofNullable(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID)).ifPresent( + v -> sensorContext.addContextProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID, v)); // AZURE DEVOPS 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 16a2b7622..717d4a68b 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(10, result.size()); + assertEquals(11, 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 c77d7c22a..ad6545167 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 @@ -855,4 +855,48 @@ public void testCreateAnalysisIssueSummary() { ) ); } + + @Test + public void testFakeIdReturnedForSummaryComment() { + 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),"", mock(ScannerContext.class)); + assertThat(analysisDetails.parseIssueIdFromUrl("https://sonarqube.dummy/path/dashboard?pullRequest=123")) + .isEqualTo(Optional.of("decorator-summary-comment")); + } + + @Test + public void testIssueIdReturnedForHotspotUrl() { + 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),"", mock(ScannerContext.class)); + assertThat(analysisDetails.parseIssueIdFromUrl("http://subdomain.sonarqube.dummy/path/security_hotspots?hotspots=A1B2-Z9Y8X7")) + .isEqualTo(Optional.of("A1B2-Z9Y8X7")); + } + + @Test + public void testNoIssueIdReturnedForHotspotUrlWithoutId() { + 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),"", mock(ScannerContext.class)); + assertThat(analysisDetails.parseIssueIdFromUrl("http://subdomain.sonarqube.dummy/path/security_hotspots?other_parameter=ABC")) + .isEmpty(); + } + + @Test + public void testIssueIdReturnedForIssueUrl() { + 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),"", mock(ScannerContext.class)); + assertThat(analysisDetails.parseIssueIdFromUrl("http://subdomain.sonarqube.dummy/path/issue?issues=XXX-YYY-ZZZ")) + .isEqualTo(Optional.of("XXX-YYY-ZZZ")); + } + + @Test + public void testNoIssueIdReturnedForIssueUrlWithoutId() { + 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),"", mock(ScannerContext.class)); + assertThat(analysisDetails.parseIssueIdFromUrl("http://subdomain.sonarqube.dummy/path/issue?other_parameter=123")).isEmpty(); + } } diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReaderTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReaderTest.java new file mode 100644 index 000000000..3c7316e4d --- /dev/null +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DefaultLinkHeaderReaderTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultLinkHeaderReaderTest { + + @ParameterizedTest(name = "{arguments}") + @CsvSource({"Missing Header,", + "Empty Header,''", + "Missing rel Content,; rel", + "Invalid rel Content,; abc=\"xyz\"", + "Incorrect Header,dummy", + "Missing URL Prefix,http://other>; rel=\"next\"", + "Missing URL Postfix,; rel=\"next\"")).hasValue("http://other"); + } + + @Test + void findNextLinkReturnsCorrectUrlOnMatchNoSpeechMarksAroundRel() { + DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); + assertThat(underTest.findNextLink("; rel=next")).hasValue("http://other"); + } + + @Test + void findNextLinkReturnsCorrectUrlOnMatchWithOtherRelEntries() { + DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); + assertThat(underTest.findNextLink("; rel=\"last\", ; rel=\"next\"")).hasValue("http://other2"); + } +} diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReaderTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReaderTest.java deleted file mode 100644 index ad803e799..000000000 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/DefaultLinkHeaderReaderTest.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (C) 2020 Michael Clarke - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3; - -import org.junit.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class DefaultLinkHeaderReaderTest { - - @Test - public void findNextLinkEmptyForNoHeader() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink(null)).isEmpty(); - } - - @Test - public void findNextLinkEmptyForEmptyHeader() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("")).isEmpty(); - } - - @Test - public void findNextLinkEmptyForMissingRelContent() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("; rel")).isEmpty(); - } - - @Test - public void findNextLinkEmptyForInvalidRelContent() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("; abc=\"xyz\"")).isEmpty(); - } - - @Test - public void findNextLinkEmptyForIncorrectHeader() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("dummy")).isEmpty(); - } - - @Test - public void findNextLinkEmptyForMissingUrlPrefix() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("http://other>; rel=\"next\"")).isEmpty(); - } - - @Test - public void findNextLinkEmptyForMissingUrlPostfix() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("; rel=\"next\"")).hasValue("http://other"); - } - - @Test - public void findNextLinkReturnsCorrectUrlOnMatchNoSpeechMarksAroundRel() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("; rel=next")).hasValue("http://other"); - } - - @Test - public void findNextLinkReturnsCorrectUrlOnMatchWithOtherRelEntries() { - DefaultLinkHeaderReader underTest = new DefaultLinkHeaderReader(); - assertThat(underTest.findNextLink("; rel=\"last\", ; rel=\"next\"")).hasValue("http://other2"); - } -} \ No newline at end of file diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java index 7ec3d95a5..c8a8ab574 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java @@ -18,6 +18,7 @@ */ package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.LinkHeaderReader; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.RepositoryAuthenticationToken; import org.apache.commons.io.IOUtils; import org.junit.Test; diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorIntegrationTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorIntegrationTest.java new file mode 100644 index 000000000..ecae71c0c --- /dev/null +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorIntegrationTest.java @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2019-2021 Markus Heberling, Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +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.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.ce.task.projectanalysis.component.Component; +import org.sonar.ce.task.projectanalysis.scm.Changeset; +import org.sonar.ce.task.projectanalysis.scm.ScmInfo; +import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepository; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; + +import java.io.UnsupportedEncodingException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.github.tomakehurst.wiremock.client.WireMock.created; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GitlabMergeRequestDecoratorIntegrationTest { + + @Rule + public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig()); + + @Test + public void decorateQualityGateStatusOk() { + decorateQualityGateStatus(QualityGate.Status.OK); + } + + @Test + public void decorateQualityGateStatusError() { + decorateQualityGateStatus(QualityGate.Status.ERROR); + } + + private void decorateQualityGateStatus(QualityGate.Status status) { + String user = "sonar_user"; + String repositorySlug = "repo/slug"; + String commitSHA = "commitSHA"; + long mergeRequestIid = 6; + String projectKey = "projectKey"; + String sonarRootUrl = "http://sonar:9000/sonar"; + String discussionId = "6a9c1750b37d513a43987b574953fceb50b03ce7"; + String noteId = "1126"; + String filePath = "/path/to/file"; + long sourceProjectId = 1234; + int lineNumber = 5; + + ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); + AlmSettingDto almSettingDto = mock(AlmSettingDto.class); + when(almSettingDto.getPersonalAccessToken()).thenReturn("token"); + + AnalysisDetails analysisDetails = mock(AnalysisDetails.class); + when(analysisDetails.getScannerProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL)) + .thenReturn(Optional.of(wireMockRule.baseUrl()+"/api/v4")); + when(analysisDetails + .getScannerProperty(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID)) + .thenReturn(Optional.of(repositorySlug)); + when(analysisDetails.getQualityGateStatus()).thenReturn(status); + when(analysisDetails.getAnalysisProjectKey()).thenReturn(projectKey); + when(analysisDetails.getBranchName()).thenReturn(Long.toString(mergeRequestIid)); + when(analysisDetails.getCommitSha()).thenReturn(commitSHA); + when(analysisDetails.getCoverage()).thenReturn(Optional.of(BigDecimal.TEN)); + PostAnalysisIssueVisitor issueVisitor = mock(PostAnalysisIssueVisitor.class); + + ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); + + List issues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + PostAnalysisIssueVisitor.LightIssue defaultIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(defaultIssue.getLine()).thenReturn(lineNumber); + when(defaultIssue.key()).thenReturn("issueKey" + i); + when(componentIssue.getIssue()).thenReturn(defaultIssue); + Component component = mock(Component.class); + when(componentIssue.getComponent()).thenReturn(component); + when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of(filePath)); + + ScmInfo scmInfo = mock(ScmInfo.class); + when(scmInfo.hasChangesetForLine(anyInt())).thenReturn(true); + when(scmInfo.getChangesetForLine(anyInt())).thenReturn(Changeset.newChangesetBuilder() + .setDate(0L) + .setRevision(commitSHA) + .build()); + when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); + + issues.add(componentIssue); + } + when(issueVisitor.getIssues()).thenReturn(issues); + when(analysisDetails.getPostAnalysisIssueVisitor()).thenReturn(issueVisitor); + when(analysisDetails.createAnalysisSummary(any())).thenReturn("summary comment\n\n[link text]"); + when(analysisDetails.createAnalysisIssueSummary(any(), any())).thenReturn("issue"); + when(analysisDetails.parseIssueIdFromUrl(any())).thenCallRealMethod(); + + wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/user")).withHeader("PRIVATE-TOKEN", equalTo("token")).willReturn(okJson("{\n" + + " \"id\": 1,\n" + + " \"username\": \"" + user + "\"}"))); + + wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + mergeRequestIid)).willReturn(okJson("{\n" + + " \"id\": 15235,\n" + + " \"iid\": " + mergeRequestIid + ",\n" + + " \"web_url\": \"http://gitlab.example.com/my-group/my-project/merge_requests/1\",\n" + + " \"diff_refs\": {\n" + + " \"base_sha\":\"d6a420d043dfe85e7c240fd136fc6e197998b10a\",\n" + + " \"head_sha\":\"" + commitSHA + "\",\n" + + " \"start_sha\":\"d6a420d043dfe85e7c240fd136fc6e197998b10a\"\n" + + " }," + + " \"source_project_id\": " + sourceProjectId + "\n" + + "}"))); + + wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/commits")).willReturn(okJson("[\n" + + " {\n" + + " \"id\": \"" + commitSHA + "\"\n" + + " }]"))); + + wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions")).willReturn(okJson( + "[\n" + discussionPostResponseBody(discussionId, + discussionNote(noteId, user, "Old sonarqube issue.\\nPlease fix this finding", true, false), + discussionNote(noteId + 1, "other", "I have fixed this", true, false)) + + ", " + + discussionPostResponseBody(discussionId + 1, + discussionNote(noteId + 2, user, "Old Sonarqube issue that no longer exists\\n[View in SonarQube](https://sonarqube.dummy/security_hotspots?id=" + projectKey + "&pullRequest=1234&hotspots=randomId)", true, false), + discussionNote(noteId + 3, "other", "System message about this being changed in a commit", false, true)) + + "," + + discussionPostResponseBody(discussionId + 2, + discussionNote(noteId + 4, user, "Not a Sonarqube issue, but posted by the same user as Sonarqube, so will be cleaned up", true, false), + discussionNote(noteId + 5, "other", "Investigating", true, false)) + + "," + + discussionPostResponseBody(discussionId + 3, + discussionNote(noteId + 6, "other", "not posted by the Sonarqube user", true, false)) + + "," + + discussionPostResponseBody(discussionId + 4, + discussionNote(noteId + 7, user, "Posted by system on behalf of Sonarqube user", false, true)) + + "," + + discussionPostResponseBody(discussionId + 5, + discussionNote(noteId + 8, user, "Ongoing sonarqube issue that should not be closed\\n[View in SonarQube](https://sonarqube.dummy/security_hotspots?id=" + projectKey + "&pullRequest=1234&hotspots=issueKey1)", true, false)) + + "," + + discussionPostResponseBody(discussionId + 6, + discussionNote(noteId + 9, user, "Resolved Sonarqube issue with response comment from other user so discussion can't be closed\\n[View in SonarQube](https://sonarqube.dummy/project/issues?id=" + projectKey + "&pullRequest=1234&issues=oldid&open=oldid)", true, false), + discussionNote(noteId + 10, "other", "Comment from other user", true, false)) + + "]"))); + + wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + "/notes")) + .withRequestBody(equalTo("body=" + urlEncode("This looks like a comment from an old SonarQube version, but due to other comments being present in this discussion, the discussion is not being being closed automatically. Please manually resolve this discussion once the other comments have been reviewed."))) + .willReturn(created())); + + wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + 2 + "/notes")) + .withRequestBody(equalTo("body=" + urlEncode("This looks like a comment from an old SonarQube version, but due to other comments being present in this discussion, the discussion is not being being closed automatically. Please manually resolve this discussion once the other comments have been reviewed."))) + .willReturn(created())); + + wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + 6 + "/notes")) + .withRequestBody(equalTo("body=" + urlEncode("This issue no longer exists in SonarQube, but due to other comments being present in this discussion, the discussion is not being being closed automatically. Please manually resolve this discussion once the other comments have been reviewed."))) + .willReturn(created())); + + wireMockRule.stubFor(post(urlEqualTo("/api/v4/projects/" + sourceProjectId + "/statuses/" + commitSHA + "?state=" + (status == QualityGate.Status.OK ? "success" : "failed"))) + .withRequestBody(equalTo("name=SonarQube&target_url=" + urlEncode(sonarRootUrl + "/dashboard?id=" + projectKey + "&pullRequest=" + mergeRequestIid) + "&description=SonarQube+Status&coverage=10")) + .willReturn(created())); + + wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions")) + .withRequestBody(equalTo("body=summary+comment%0A%0A%5Blink+text%5D")) + .willReturn(created().withBody(discussionPostResponseBody(discussionId, discussionNote(noteId, user, "summary comment", true, false))))); + + wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions")) + .withRequestBody(equalTo("body=issue&" + + urlEncode("position[base_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + + urlEncode("position[start_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + + urlEncode("position[head_sha]") + "=" + commitSHA + "&" + + urlEncode("position[old_path]") + "=" + urlEncode(filePath) + "&" + + urlEncode("position[new_path]") + "=" + urlEncode(filePath) + "&" + + urlEncode("position[new_line]") + "=" + lineNumber + "&" + + urlEncode("position[position_type]") + "=text")) + .willReturn(created().withBody(discussionPostResponseBody(discussionId, discussionNote(noteId, user, "issue",true, false))))); + + wireMockRule.stubFor(put(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId)) + .withQueryParam("resolved", equalTo("true")) + .willReturn(ok()) + ); + + wireMockRule.stubFor(put(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + 1)) + .withQueryParam("resolved", equalTo("true")) + .willReturn(ok()) + ); + + Server server = mock(Server.class); + when(server.getPublicRootUrl()).thenReturn(sonarRootUrl); + GitlabMergeRequestDecorator pullRequestDecorator = + new GitlabMergeRequestDecorator(server, scmInfoRepository, new DefaultGitlabClientFactory()); + + + assertThat(pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto).getPullRequestUrl()).isEqualTo(Optional.of("http://gitlab.example.com/my-group/my-project/merge_requests/1")); + } + + private static String discussionPostResponseBody(String discussionId, String... notes) { + return "{\n" + + " \"id\": \"" + discussionId + "\",\n" + + " \"individual_note\": false,\n" + + " \"notes\": [\n" + String.join(", ", notes) + "\n]" + + "}"; + } + + private static String discussionNote(String noteId, String username, String noteBody, boolean resolvable, boolean isSystem) { + return "{\n" + + " \"id\": " + noteId + ",\n" + + " \"type\": \"DiscussionNote\",\n" + + " \"body\": \"" + noteBody + "\",\n" + + " \"attachment\": null,\n" + + " \"author\": {\n" + + " \"id\": 1,\n" + + " \"username\": \"" + username + "\"\n" + + " },\n" + + " \"resolved\": \"false\",\n" + + " \"system\": \"" + isSystem + "\",\n" + + " \"resolvable\": \"" + resolvable + "\"\n" + + "}"; + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new Error("No support for UTF-8!", e); + } + } +} diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorTest.java new file mode 100644 index 000000000..ca5dbcc59 --- /dev/null +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabMergeRequestDecoratorTest.java @@ -0,0 +1,731 @@ +/* + * Copyright (C) 2021 Michael Clarke + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + */ +package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; + +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.gitlab.model.Commit; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.CommitNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.DiffRefs; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Discussion; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequest; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.MergeRequestNote; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.Note; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.PipelineStatus; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.model.User; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.sonar.api.ce.posttask.QualityGate; +import org.sonar.api.issue.Issue; +import org.sonar.api.platform.Server; +import org.sonar.ce.task.projectanalysis.component.Component; +import org.sonar.ce.task.projectanalysis.scm.Changeset; +import org.sonar.ce.task.projectanalysis.scm.ScmInfo; +import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepository; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class GitlabMergeRequestDecoratorTest { + + private static final long MERGE_REQUEST_IID = 123; + private static final long PROJECT_ID = 101; + private static final String PROJECT_PATH = "dummy/repo"; + private static final String PROJECT_KEY = "projectKey"; + private static final String ANALYSIS_UUID = "analysis-uuid"; + private static final String SONARQUBE_USERNAME = "sonarqube@gitlab.dummy"; + private static final String BASE_SHA = "baseSha"; + private static final String HEAD_SHA = "headSha"; + private static final String START_SHA = "startSha"; + private static final String MERGE_REQUEST_WEB_URL = "https://gitlab.dummy/path/to/mr"; + private static final String OLD_SONARQUBE_VERSION_COMMENT = "This looks like a comment from an old SonarQube version, " + + "but due to other comments being present in this discussion, " + + "the discussion is not being being closed automatically. " + + "Please manually resolve this discussion once the other comments have been reviewed."; + private static final String OLD_SONARQUBE_ISSUE_COMMENT = "This issue no longer exists in SonarQube, " + + "but due to other comments being present in this discussion, " + + "the discussion is not being being closed automatically. " + + "Please manually resolve this discussion once the other comments have been reviewed."; + + private final GitlabClient gitlabClient = mock(GitlabClient.class); + private final GitlabClientFactory gitlabClientFactory = mock(GitlabClientFactory.class); + private final Server server = mock(Server.class); + private final ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); + private final AnalysisDetails analysisDetails = mock(AnalysisDetails.class); + private final AlmSettingDto almSettingDto = mock(AlmSettingDto.class); + private final ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); + private final MergeRequest mergeRequest = mock(MergeRequest.class); + private final User sonarqubeUser = mock(User.class); + private final PostAnalysisIssueVisitor postAnalysisIssueVisitor = mock(PostAnalysisIssueVisitor.class); + private final DiffRefs diffRefs = mock(DiffRefs.class); + + private final GitlabMergeRequestDecorator underTest = new GitlabMergeRequestDecorator(server, scmInfoRepository, gitlabClientFactory); + + @Before + public void setUp() throws IOException { + when(gitlabClientFactory.createClient(any(), any())).thenReturn(gitlabClient); + when(almSettingDto.getUrl()).thenReturn("http://gitlab.dummy"); + when(projectAlmSettingDto.getAlmRepo()).thenReturn(PROJECT_PATH); + when(analysisDetails.getBranchName()).thenReturn(Long.toString(MERGE_REQUEST_IID)); + when(mergeRequest.getIid()).thenReturn(MERGE_REQUEST_IID); + when(mergeRequest.getSourceProjectId()).thenReturn(PROJECT_ID); + when(mergeRequest.getDiffRefs()).thenReturn(diffRefs); + when(mergeRequest.getWebUrl()).thenReturn(MERGE_REQUEST_WEB_URL); + when(diffRefs.getBaseSha()).thenReturn(BASE_SHA); + when(diffRefs.getHeadSha()).thenReturn(HEAD_SHA); + when(diffRefs.getStartSha()).thenReturn(START_SHA); + when(gitlabClient.getMergeRequest(PROJECT_PATH, MERGE_REQUEST_IID)).thenReturn(mergeRequest); + when(gitlabClient.getMergeRequestCommits(PROJECT_ID, MERGE_REQUEST_IID)).thenReturn(Arrays.stream(new String[]{"ABC", "DEF", "GHI", "JKL"}) + .map(Commit::new) + .collect(Collectors.toList())); + when(sonarqubeUser.getUsername()).thenReturn(SONARQUBE_USERNAME); + when(gitlabClient.getCurrentUser()).thenReturn(sonarqubeUser); + when(analysisDetails.getPostAnalysisIssueVisitor()).thenReturn(postAnalysisIssueVisitor); + when(analysisDetails.getAnalysisProjectKey()).thenReturn(PROJECT_KEY); + when(analysisDetails.getAnalysisId()).thenReturn(ANALYSIS_UUID); + when(postAnalysisIssueVisitor.getIssues()).thenReturn(new ArrayList<>()); + } + + @Test + public void shouldReturnCorrectDecoratorType() { + assertThat(underTest.alm()).containsOnly(ALM.GITLAB); + } + + @Test + public void shouldThrowErrorWhenInstanceUrlNotSetInDtoOrScannerProperties() { + when(almSettingDto.getUrl()).thenReturn(" "); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("'sonar.pullrequest.gitlab.instanceUrl' has not been set in scanner properties"); + } + + @Test + public void shouldThrowErrorWhenProjectIdNotSetInDtoOrScannerProperties() { + when(projectAlmSettingDto.getAlmRepo()).thenReturn(" "); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("'sonar.pullrequest.gitlab.projectId' has not been set in scanner properties"); + } + + @Test + public void shouldThrowErrorWhenPullRequestKeyNotNumeric() { + when(analysisDetails.getBranchName()).thenReturn("non-MR-IID"); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not parse Merge Request ID"); + } + + @Test + public void shouldThrowErrorWhenGitlabMergeRequestRetrievalFails() throws IOException { + when(gitlabClient.getMergeRequest(any(), anyLong())).thenThrow(new IOException("dummy")); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not retrieve Merge Request details"); + } + + @Test + public void shouldThrowErrorWhenGitlabUserRetrievalFails() throws IOException { + when(gitlabClient.getCurrentUser()).thenThrow(new IOException("dummy")); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not retrieve current user details"); + } + + @Test + public void shouldThrowErrorWhenGitlabMergeRequestCommitsRetrievalFails() throws IOException { + when(gitlabClient.getMergeRequestCommits(anyLong(), anyLong())).thenThrow(new IOException("dummy")); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not retrieve commit details for Merge Request"); + } + + @Test + public void shouldThrowErrorWhenGitlabMergeRequestDiscussionRetrievalFails() throws IOException { + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenThrow(new IOException("dummy")); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not retrieve Merge Request discussions"); + } + + @Test + public void shouldCloseDiscussionWithSingleResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Post with no issue ID"); + when(note.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId"); + when(discussion.getNotes()).thenReturn(Collections.singletonList(note)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient).addMergeRequestDiscussion(anyLong(), anyLong(), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); } + + @Test + public void shouldNotCloseDiscussionWithSingleNonResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Post with no issue ID"); + when(note.isResolvable()).thenReturn(false); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId"); + when(discussion.getNotes()).thenReturn(Collections.singletonList(note)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + } + + @Test + public void shouldNotCloseDiscussionWithMultipleResolvableNotesFromSonarqubeUserButNoId() throws IOException { + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Another post with no issue ID\nbut containing a new line"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(sonarqubeUser); + when(note2.getBody()).thenReturn("Additional post from user"); + when(note2.isResolvable()).thenReturn(true); + + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId2"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient).addMergeRequestDiscussion(anyLong(), anyLong(), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); + } + + @Test + public void shouldCloseDiscussionWithResolvableNoteFromSonarqubeUserAndOnlySystemNoteFromOtherUser() throws IOException { + User otherUser = mock(User.class); + when(otherUser.getUsername()).thenReturn("other.user@gitlab.dummy"); + + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("[View in SonarQube](url)"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("System post on behalf of user"); + when(note2.isSystem()).thenReturn(true); + + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId2"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2)); + + when(analysisDetails.parseIssueIdFromUrl("url")).thenReturn(Optional.of("issueId")); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor discussionIdArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(gitlabClient).resolveMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), discussionIdArgumentCaptor.capture()); + + assertThat(discussionIdArgumentCaptor.getValue()).isEqualTo(discussion.getId()); + } + + @Test + public void shouldNotAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithNoId() throws IOException { + User otherUser = mock(User.class); + when(otherUser.getUsername()).thenReturn("other.user@github.dummy"); + + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Yet another post with no issue ID"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("Post from another user"); + when(note2.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId3"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient).addMergeRequestDiscussion(anyLong(), anyLong(), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); + } + + @Test + public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndACloseMessageWithNoId() throws IOException { + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("And another post with no issue ID\nNo View in SonarQube link"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(sonarqubeUser); + when(note2.getBody()).thenReturn(OLD_SONARQUBE_VERSION_COMMENT); + when(note2.isResolvable()).thenReturn(true); + + Note note3 = mock(Note.class); + when(note3.getAuthor()).thenReturn(sonarqubeUser); + when(note3.getBody()).thenReturn("other comment"); + when(note3.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId4"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2, note3)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + } + + @Test + public void shouldCommentAboutCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithIssuedId() throws IOException { + User otherUser = mock(User.class); + when(otherUser.getUsername()).thenReturn("other.user@github.dummy"); + + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Sonarqube reported issue\n[View in SonarQube](https://dummy.url.with.subdomain/path/to/sonarqube?paramters=many&values=complex%20and+encoded)"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("Message from another user"); + when(note2.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId5"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2)); + + when(analysisDetails.parseIssueIdFromUrl("https://dummy.url.with.subdomain/path/to/sonarqube?paramters=many&values=complex%20and+encoded")).thenReturn(Optional.of("new-issue")); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + + ArgumentCaptor discussionIdArgumentCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor noteContentArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(gitlabClient).addMergeRequestDiscussionNote(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), discussionIdArgumentCaptor.capture(), noteContentArgumentCaptor.capture()); + + assertThat(discussionIdArgumentCaptor.getValue()).isEqualTo(discussion.getId()); + assertThat(noteContentArgumentCaptor.getValue()).isEqualTo(OLD_SONARQUBE_ISSUE_COMMENT); + } + + @Test + public void shouldThrowErrorIfUnableToCleanUpDiscussionOnGitlab() throws IOException { + User otherUser = mock(User.class); + when(otherUser.getUsername()).thenReturn("other.user@github.dummy"); + + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Sonarqube reported issue\n[View in SonarQube](https://dummy.url.with.subdomain/path/to/sonarqube?paramters=many&values=complex%20and+encoded)"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("Message from another user"); + when(note2.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId5"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2)); + + when(analysisDetails.parseIssueIdFromUrl("https://dummy.url.with.subdomain/path/to/sonarqube?paramters=many&values=complex%20and+encoded")).thenReturn(Optional.of("issueId")); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + doThrow(new IOException("dummy")).when(gitlabClient).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not clean-up discussion on Gitlab"); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + + ArgumentCaptor discussionIdArgumentCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor noteContentArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(gitlabClient).addMergeRequestDiscussionNote(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), discussionIdArgumentCaptor.capture(), noteContentArgumentCaptor.capture()); + + assertThat(discussionIdArgumentCaptor.getValue()).isEqualTo(discussion.getId()); + assertThat(noteContentArgumentCaptor.getValue()).isEqualTo(OLD_SONARQUBE_ISSUE_COMMENT); + } + + @Test + public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndACloseMessageWithIssueId() throws IOException { + Note note = mock(Note.class); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("And another post with an issue ID\n[View in SonarQube](url)"); + when(note.isResolvable()).thenReturn(true); + + Note note2 = mock(Note.class); + when(note2.getAuthor()).thenReturn(sonarqubeUser); + when(note2.getBody()).thenReturn(OLD_SONARQUBE_ISSUE_COMMENT); + when(note2.isResolvable()).thenReturn(true); + + Note note3 = mock(Note.class); + when(note3.getAuthor()).thenReturn(sonarqubeUser); + when(note3.getBody()).thenReturn("Some additional comment"); + when(note3.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussionId6"); + when(discussion.getNotes()).thenReturn(Arrays.asList(note, note2, note3)); + + when(analysisDetails.parseIssueIdFromUrl("url")).thenReturn(Optional.of("issueId")); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + } + + @Test + public void shouldThrowErrorIfSubmittingNewIssueToGitlabFails() throws IOException { + PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + when(lightIssue.key()).thenReturn("issueKey1"); + when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(lightIssue.getLine()).thenReturn(999); + + Component component = mock(Component.class); + + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + when(componentIssue.getIssue()).thenReturn(lightIssue); + when(componentIssue.getComponent()).thenReturn(component); + + when(postAnalysisIssueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(new ArrayList<>()); + when(analysisDetails.createAnalysisIssueSummary(eq(componentIssue), any())).thenReturn("Issue Summary"); + when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of("path-to-file")); + + Changeset changeset = mock(Changeset.class); + when(changeset.getRevision()).thenReturn("DEF"); + + ScmInfo scmInfo = mock(ScmInfo.class); + when(scmInfo.hasChangesetForLine(999)).thenReturn(true); + when(scmInfo.getChangesetForLine(999)).thenReturn(changeset); + when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); + + when(gitlabClient.addMergeRequestDiscussion(anyLong(), anyLong(), any())).thenThrow(new IOException("dummy")); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not submit commit comment to Gitlab"); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isEqualToComparingFieldByField(new CommitNote("Issue Summary", BASE_SHA, START_SHA, HEAD_SHA, "path-to-file", "path-to-file", 999)); + } + + @Test + public void shouldStartNewDiscussionForNewIssueFromCommitInMergeRequest() throws IOException { + PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + when(lightIssue.key()).thenReturn("issueKey1"); + when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(lightIssue.getLine()).thenReturn(999); + + Component component = mock(Component.class); + + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + when(componentIssue.getIssue()).thenReturn(lightIssue); + when(componentIssue.getComponent()).thenReturn(component); + + when(postAnalysisIssueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(new ArrayList<>()); + when(analysisDetails.createAnalysisIssueSummary(eq(componentIssue), any())).thenReturn("Issue Summary"); + when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of("path-to-file")); + + Changeset changeset = mock(Changeset.class); + when(changeset.getRevision()).thenReturn("DEF"); + + ScmInfo scmInfo = mock(ScmInfo.class); + when(scmInfo.hasChangesetForLine(999)).thenReturn(true); + when(scmInfo.getChangesetForLine(999)).thenReturn(changeset); + when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient, times(2)).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getAllValues().get(0)).isEqualToComparingFieldByField(new CommitNote("Issue Summary", BASE_SHA, START_SHA, HEAD_SHA, "path-to-file", "path-to-file", 999)); + assertThat(mergeRequestNoteArgumentCaptor.getAllValues().get(1)).isNotInstanceOf(CommitNote.class); + } + + @Test + public void shouldNotStartNewDiscussionForIssueWithExistingCommentFromCommitInMergeRequest() throws IOException { + PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + when(lightIssue.key()).thenReturn("issueKey1"); + when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(lightIssue.getLine()).thenReturn(999); + + Component component = mock(Component.class); + + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + when(componentIssue.getIssue()).thenReturn(lightIssue); + when(componentIssue.getComponent()).thenReturn(component); + + Note note = mock(Note.class); + when(note.getBody()).thenReturn("Reported issue\n[View in SonarQube](url)"); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.isResolvable()).thenReturn(true); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("discussion-id"); + when(discussion.getNotes()).thenReturn(Collections.singletonList(note)); + + when(analysisDetails.parseIssueIdFromUrl("url")).thenReturn(Optional.of("issueKey1")); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + when(postAnalysisIssueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); + when(analysisDetails.createAnalysisIssueSummary(eq(componentIssue), any())).thenReturn("Issue Summary"); + when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of("path-to-file")); + + Changeset changeset = mock(Changeset.class); + when(changeset.getRevision()).thenReturn("DEF"); + + ScmInfo scmInfo = mock(ScmInfo.class); + when(scmInfo.hasChangesetForLine(999)).thenReturn(true); + when(scmInfo.getChangesetForLine(999)).thenReturn(changeset); + when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); + } + + @Test + public void shouldNotCreateCommentsForIssuesWithNoLineNumbers() throws IOException { + PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + when(lightIssue.key()).thenReturn("issueKey1"); + when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(lightIssue.getLine()).thenReturn(null); + + Component component = mock(Component.class); + + PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + when(componentIssue.getIssue()).thenReturn(lightIssue); + when(componentIssue.getComponent()).thenReturn(component); + + when(postAnalysisIssueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(new ArrayList<>()); + when(analysisDetails.createAnalysisIssueSummary(eq(componentIssue), any())).thenReturn("Issue Summary"); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).resolveMergeRequestDiscussion(anyLong(), anyLong(), any()); + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + verify(scmInfoRepository, never()).getScmInfo(any()); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); + } + + @Test + public void shouldSubmitSuccessfulPipelineStatusAndResolvedSummaryCommentOnSuccessAnalysis() throws IOException { + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); + when(analysisDetails.createAnalysisSummary(any())).thenReturn("Summary comment"); + when(analysisDetails.getCommitSha()).thenReturn("commitsha"); + + when(server.getPublicRootUrl()).thenReturn("https://sonarqube.dummy"); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("dicussion id"); + when(gitlabClient.addMergeRequestDiscussion(anyLong(), anyLong(), any())).thenReturn(discussion); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + verify(gitlabClient).resolveMergeRequestDiscussion(PROJECT_ID, MERGE_REQUEST_IID, discussion.getId()); + ArgumentCaptor pipelineStatusArgumentCaptor = ArgumentCaptor.forClass(PipelineStatus.class); + verify(gitlabClient).setMergeRequestPipelineStatus(eq(PROJECT_ID), eq("commitsha"), pipelineStatusArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isEqualToComparingFieldByField(new MergeRequestNote("Summary comment")); + assertThat(pipelineStatusArgumentCaptor.getValue()) + .isEqualToComparingFieldByField(new PipelineStatus("SonarQube", "SonarQube Status", + PipelineStatus.State.SUCCESS, "https://sonarqube.dummy/dashboard?id=" + PROJECT_KEY + "&pullRequest=" + MERGE_REQUEST_IID, null, null)); + } + + @Test + public void shouldSubmitFailedPipelineStatusAndUnresolvedSummaryCommentOnFailedAnalysis() throws IOException { + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); + when(analysisDetails.createAnalysisSummary(any())).thenReturn("Different Summary comment"); + when(analysisDetails.getCommitSha()).thenReturn("other sha"); + when(analysisDetails.getCoverage()).thenReturn(Optional.of(BigDecimal.TEN)); + when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); + + when(server.getPublicRootUrl()).thenReturn("https://sonarqube2.dummy"); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("dicussion id 2"); + when(gitlabClient.addMergeRequestDiscussion(anyLong(), anyLong(), any())).thenReturn(discussion); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(PROJECT_ID, MERGE_REQUEST_IID, discussion.getId()); + ArgumentCaptor pipelineStatusArgumentCaptor = ArgumentCaptor.forClass(PipelineStatus.class); + verify(gitlabClient).setMergeRequestPipelineStatus(eq(PROJECT_ID), eq("other sha"), pipelineStatusArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isEqualToComparingFieldByField(new MergeRequestNote("Different Summary comment")); + assertThat(pipelineStatusArgumentCaptor.getValue()) + .isEqualToComparingFieldByField(new PipelineStatus("SonarQube", "SonarQube Status", + PipelineStatus.State.FAILED, "https://sonarqube2.dummy/dashboard?id=" + PROJECT_KEY + "&pullRequest=" + MERGE_REQUEST_IID, BigDecimal.TEN, 11L)); + } + + @Test + public void shouldThrowErrorWhenSubmitPipelineStatusToGitlabFails() throws IOException { + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); + when(analysisDetails.createAnalysisSummary(any())).thenReturn("Different Summary comment"); + when(analysisDetails.getCommitSha()).thenReturn("other sha"); + when(analysisDetails.getCoverage()).thenReturn(Optional.of(BigDecimal.TEN)); + when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); + + when(server.getPublicRootUrl()).thenReturn("https://sonarqube2.dummy"); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("dicussion id 2"); + when(gitlabClient.addMergeRequestDiscussion(anyLong(), anyLong(), any())).thenReturn(discussion); + doThrow(new IOException("dummy")).when(gitlabClient).setMergeRequestPipelineStatus(anyLong(), any(), any()); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not update pipeline status in Gitlab"); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(PROJECT_ID, MERGE_REQUEST_IID, discussion.getId()); + ArgumentCaptor pipelineStatusArgumentCaptor = ArgumentCaptor.forClass(PipelineStatus.class); + verify(gitlabClient).setMergeRequestPipelineStatus(eq(PROJECT_ID), eq("other sha"), pipelineStatusArgumentCaptor.capture()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isEqualToComparingFieldByField(new MergeRequestNote("Different Summary comment")); + assertThat(pipelineStatusArgumentCaptor.getValue()) + .isEqualToComparingFieldByField(new PipelineStatus("SonarQube", "SonarQube Status", + PipelineStatus.State.FAILED, "https://sonarqube2.dummy/dashboard?id=" + PROJECT_KEY + "&pullRequest=" + MERGE_REQUEST_IID, BigDecimal.TEN, 11L)); + } + + @Test + public void shouldThrowErrorWhenSubmitAnalysisToGitlabFails() throws IOException { + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); + when(analysisDetails.createAnalysisSummary(any())).thenReturn("Different Summary comment"); + when(analysisDetails.getCommitSha()).thenReturn("other sha"); + when(analysisDetails.getCoverage()).thenReturn(Optional.of(BigDecimal.TEN)); + when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); + + when(server.getPublicRootUrl()).thenReturn("https://sonarqube2.dummy"); + + Discussion discussion = mock(Discussion.class); + when(discussion.getId()).thenReturn("dicussion id 2"); + when(gitlabClient.addMergeRequestDiscussion(anyLong(), anyLong(), any())).thenReturn(discussion); + doThrow(new IOException("dummy")).when(gitlabClient).addMergeRequestDiscussion(anyLong(), anyLong(), any()); + + assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Could not submit summary comment to Gitlab"); + + ArgumentCaptor mergeRequestNoteArgumentCaptor = ArgumentCaptor.forClass(MergeRequestNote.class); + verify(gitlabClient).addMergeRequestDiscussion(eq(PROJECT_ID), eq(MERGE_REQUEST_IID), mergeRequestNoteArgumentCaptor.capture()); + verify(gitlabClient, never()).resolveMergeRequestDiscussion(PROJECT_ID, MERGE_REQUEST_IID, discussion.getId()); + verify(gitlabClient, never()).setMergeRequestPipelineStatus(anyLong(), any(), any()); + + assertThat(mergeRequestNoteArgumentCaptor.getValue()).isEqualToComparingFieldByField(new MergeRequestNote("Different Summary comment")); + } + + @Test + public void shouldReturnWebUrlFromMergeRequestIfScannerPropertyNotSet() { + assertThat(underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isEqualToComparingFieldByField(DecorationResult.builder().withPullRequestUrl(MERGE_REQUEST_WEB_URL).build()); + } + + @Test + public void shouldReturnWebUrlFromScannerPropertyIfSet() { + when(analysisDetails.getScannerProperty("sonar.pullrequest.gitlab.projectUrl")).thenReturn(Optional.of(MERGE_REQUEST_WEB_URL + "/additional")); + assertThat(underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) + .isEqualToComparingFieldByField(DecorationResult.builder().withPullRequestUrl(MERGE_REQUEST_WEB_URL + "/additional/merge_requests/" + MERGE_REQUEST_IID).build()); + } +} diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecoratorTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecoratorTest.java deleted file mode 100644 index 4ef154d50..000000000 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/gitlab/GitlabServerPullRequestDecoratorTest.java +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright (C) 2019 Markus Heberling - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - */ -package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; - -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.junit.Rule; -import org.junit.Test; -import org.mockito.Mockito; -import org.sonar.api.ce.posttask.QualityGate; -import org.sonar.api.issue.Issue; -import org.sonar.api.platform.Server; -import org.sonar.ce.task.projectanalysis.component.Component; -import org.sonar.ce.task.projectanalysis.scm.Changeset; -import org.sonar.ce.task.projectanalysis.scm.ScmInfo; -import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepository; -import org.sonar.db.alm.setting.AlmSettingDto; -import org.sonar.db.alm.setting.ProjectAlmSettingDto; - -import java.io.UnsupportedEncodingException; -import java.math.BigDecimal; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.Optional; - -import static com.github.tomakehurst.wiremock.client.WireMock.created; -import static com.github.tomakehurst.wiremock.client.WireMock.delete; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.matching; -import static com.github.tomakehurst.wiremock.client.WireMock.noContent; -import static com.github.tomakehurst.wiremock.client.WireMock.ok; -import static com.github.tomakehurst.wiremock.client.WireMock.okJson; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.put; -import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class GitlabServerPullRequestDecoratorTest { - - @Rule - public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig()); - - @Test - public void decorateQualityGateStatusOk() { - decorateQualityGateStatus(QualityGate.Status.OK); - } - - @Test - public void decorateQualityGateStatusError() { - decorateQualityGateStatus(QualityGate.Status.ERROR); - } - - private void decorateQualityGateStatus(QualityGate.Status status) { - String user = "sonar_user"; - String repositorySlug = "repo/slug"; - String commitSHA = "commitSHA"; - String branchName = "1"; - String projectKey = "projectKey"; - String sonarRootUrl = "http://sonar:9000/sonar"; - String discussionId = "6a9c1750b37d513a43987b574953fceb50b03ce7"; - String noteId = "1126"; - String filePath = "/path/to/file"; - String sourceProjectId = "1"; - int lineNumber = 5; - - ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); - AlmSettingDto almSettingDto = mock(AlmSettingDto.class); - when(almSettingDto.getPersonalAccessToken()).thenReturn("token"); - - AnalysisDetails analysisDetails = mock(AnalysisDetails.class); - when(analysisDetails.getScannerProperty(eq(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL))) - .thenReturn(Optional.of(wireMockRule.baseUrl()+"/api/v4")); - when(analysisDetails - .getScannerProperty(eq(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID))) - .thenReturn(Optional.of(repositorySlug)); - when(analysisDetails.getQualityGateStatus()).thenReturn(status); - when(analysisDetails.getAnalysisProjectKey()).thenReturn(projectKey); - when(analysisDetails.getBranchName()).thenReturn(branchName); - when(analysisDetails.getCommitSha()).thenReturn(commitSHA); - when(analysisDetails.getCoverage()).thenReturn(Optional.of(BigDecimal.TEN)); - PostAnalysisIssueVisitor issueVisitor = mock(PostAnalysisIssueVisitor.class); - PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); - PostAnalysisIssueVisitor.LightIssue defaultIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); - when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); - when(defaultIssue.getLine()).thenReturn(lineNumber); - when(componentIssue.getIssue()).thenReturn(defaultIssue); - Component component = mock(Component.class); - when(componentIssue.getComponent()).thenReturn(component); - when(issueVisitor.getIssues()).thenReturn(Collections.singletonList(componentIssue)); - when(analysisDetails.getPostAnalysisIssueVisitor()).thenReturn(issueVisitor); - when(analysisDetails.createAnalysisSummary(Mockito.any())).thenReturn("summary"); - when(analysisDetails.createAnalysisIssueSummary(Mockito.any(), Mockito.any())).thenReturn("issue"); - when(analysisDetails.getSCMPathForIssue(componentIssue)).thenReturn(Optional.of(filePath)); - - ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); - ScmInfo scmInfo = mock(ScmInfo.class); - when(scmInfo.hasChangesetForLine(anyInt())).thenReturn(true); - when(scmInfo.getChangesetForLine(anyInt())).thenReturn(Changeset.newChangesetBuilder().setDate(0L).setRevision(commitSHA).build()); - when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/user")).withHeader("PRIVATE-TOKEN", equalTo("token")).willReturn(okJson("{\n" + - " \"id\": 1,\n" + - " \"username\": \"" + user + "\"}"))); - - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName)).willReturn(okJson("{\n" + - " \"id\": 15235,\n" + - " \"iid\": " + branchName + ",\n" + - " \"diff_refs\": {\n" + - " \"base_sha\":\"d6a420d043dfe85e7c240fd136fc6e197998b10a\",\n" + - " \"head_sha\":\"" + commitSHA + "\",\n" + - " \"start_sha\":\"d6a420d043dfe85e7c240fd136fc6e197998b10a\"\n" + - " }," + - " \"source_project_id\": " + sourceProjectId + "\n" + - "}"))); - - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/commits")).willReturn(okJson("[\n" + - " {\n" + - " \"id\": \"" + commitSHA + "\"\n" + - " }]"))); - - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/discussions")).willReturn(okJson( - "[\n" + discussionPostResponseBody(discussionId, noteId, user) + "]"))); - - wireMockRule.stubFor(delete(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/discussions/" + discussionId + "/notes/" + noteId)).willReturn(noContent())); - - wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/statuses/" + commitSHA)) - .withQueryParam("name", equalTo("SonarQube")) - .withQueryParam("state", matching("^(failed)|(success)$")) - .withQueryParam("target_url", equalTo(sonarRootUrl + "/dashboard?id=" + projectKey + "&pullRequest=" + branchName)) - .withQueryParam("coverage", equalTo("10")) - .willReturn(created())); - - wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + urlEncode(sourceProjectId) + "/statuses/" + commitSHA)) - .withQueryParam("name", equalTo("SonarQube")) - .withQueryParam("state", matching("^(failed)|(success)$")) - .withQueryParam("target_url", equalTo(sonarRootUrl + "/dashboard?id=" + projectKey + "&pullRequest=" + branchName)) - .withQueryParam("coverage", equalTo("10")) - .willReturn(created())); - - wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/discussions")) - .withRequestBody(equalTo("body=summary")) - .willReturn(created().withBody(discussionPostResponseBody(discussionId, noteId, user)))); - - wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/discussions")) - .withRequestBody(equalTo("body=issue&" + - urlEncode("position[base_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + - urlEncode("position[start_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + - urlEncode("position[head_sha]") + "=" + commitSHA + "&" + - urlEncode("position[old_path]") + "=" + urlEncode(filePath) + "&" + - urlEncode("position[new_path]") + "=" + urlEncode(filePath) + "&" + - urlEncode("position[new_line]") + "=" + lineNumber + "&" + - urlEncode("position[position_type]") + "=text")) - .willReturn(created().withBody(discussionPostResponseBody(discussionId, noteId, user)))); - - wireMockRule.stubFor(put(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + branchName + "/discussions/" + discussionId)) - .withQueryParam("resolved", equalTo("true")) - .willReturn(ok()) - ); - - Server server = mock(Server.class); - when(server.getPublicRootUrl()).thenReturn(sonarRootUrl); - GitlabServerPullRequestDecorator pullRequestDecorator = - new GitlabServerPullRequestDecorator(server, scmInfoRepository); - - - pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); - } - - private String discussionPostResponseBody(String discussionId, String noteId, String username) { - return "{\n" + - " \"id\": \"" + discussionId + "\",\n" + - " \"individual_note\": false,\n" + - " \"notes\": [\n" + - " {\n" + - " \"id\": " + noteId + ",\n" + - " \"type\": \"DiscussionNote\",\n" + - " \"body\": \"discussion text\",\n" + - " \"attachment\": null,\n" + - " \"author\": {\n" + - " \"id\": 1,\n" + - " \"username\": \"" + username + "\"\n" + - " }}]" + - "}"; - } - - private static String urlEncode(String value) { - try { - return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); - } catch (UnsupportedEncodingException e) { - throw new Error("No support for UTF-8!", e); - } - } -} diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensorTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensorTest.java index dbba630d7..c0f7595e9 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensorTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/scanner/ScannerPullRequestPropertySensorTest.java @@ -1,5 +1,6 @@ package com.github.mc1arke.sonarqube.plugin.scanner; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.GitlabMergeRequestDecorator; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -17,7 +18,6 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops.AzureDevOpsServerPullRequestDecorator; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab.GitlabServerPullRequestDecorator; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; @@ -69,10 +69,10 @@ public void testPropertySensorWithGitlabEnvValues() throws IOException { context.fileSystem().add(inputFile); when(system2.envVariable("GITLAB_CI")).thenReturn("false"); - when(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL)).thenReturn("value"); - when(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID)).thenReturn("value"); - when(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL)).thenReturn("value"); - when(system2.property(GitlabServerPullRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID)).thenReturn("value"); + when(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_INSTANCE_URL)).thenReturn("value"); + when(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_ID)).thenReturn("value"); + when(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PROJECT_URL)).thenReturn("value"); + when(system2.property(GitlabMergeRequestDecorator.PULLREQUEST_GITLAB_PIPELINE_ID)).thenReturn("value"); sensor = new ScannerPullRequestPropertySensor(projectConfiguration, system2); sensor.execute(context); diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 000000000..1897ef41a --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +