From 6fbf8f4ea9f9e6fce0732cfb31268a860eed570f Mon Sep 17 00:00:00 2001 From: Michael Clarke Date: Sat, 16 Nov 2024 13:36:42 +0000 Subject: [PATCH] Remove old Gitlab and Azure Devops summary comments on new decoration The plugin historically left old comments in place but resolved conversations where comments had become outdated or the underlying issue had been resolved. However, in Gitlab, the summary comments always remained visible even when resolved as they were the first comment in the thread so were not minimised by the Gitlab UI. For a merge request being scanned multiple times as issues are being fixed, other review comments responded to, and rebasing activities performed, this can lead to a number of summary comments being added where the last comment is typically only the one that developers are about. As editing comments is not good practice since it's unclear what any resulting comments in the thread are referring to and Gitlab does not send emails to notify that comments have changed, the summary comment is continuing to be posted as a new comment, but the old summary comments are now being deleted. Where a thread has spawned from an old summary comment, that comment will not be deleted, but a note added to notify the users that the summary comment is outdated and the thread can be resolved once the discussion reaches a conclusion. --- .../azuredevops/AzureDevopsClient.java | 8 +- .../azuredevops/AzureDevopsRestClient.java | 16 +- .../almclient/azuredevops/model/Comment.java | 29 +- .../azuredevops/model/ConnectionData.java | 48 + .../plugin/almclient/gitlab/GitlabClient.java | 4 +- .../almclient/gitlab/GitlabRestClient.java | 9 + .../DiscussionAwarePullRequestDecorator.java | 29 +- .../AzureDevOpsPullRequestDecorator.java | 66 +- .../gitlab/GitlabMergeRequestDecorator.java | 13 +- ...psPullRequestDecoratorIntegrationTest.java | 559 ++++++++++++ .../AzureDevOpsPullRequestDecoratorTest.java | 823 ++++++------------ ...bMergeRequestDecoratorIntegrationTest.java | 108 +-- .../GitlabMergeRequestDecoratorTest.java | 144 ++- 13 files changed, 1194 insertions(+), 662 deletions(-) create mode 100644 src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/ConnectionData.java create mode 100644 src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorIntegrationTest.java diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsClient.java index 9fb0a54cc..6195ab302 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsClient.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsClient.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 Michael Clarke + * Copyright (C) 2021-2024 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -20,6 +20,7 @@ import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CommentThread; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Commit; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.ConnectionData; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CreateCommentRequest; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CreateCommentThreadRequest; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.GitPullRequestStatus; @@ -46,4 +47,9 @@ public interface AzureDevopsClient { void submitPullRequestStatus(String projectName, String repositoryName, int pullRequestId, GitPullRequestStatus status) throws IOException; Repository getRepository(String projectName, String repositoryName) throws IOException; + + void deletePullRequestThreadComment(String projectName, String repositoryName, int pullRequestId, int threadId, int commentId) throws IOException; + + ConnectionData getConnectionData() throws IOException; + } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsRestClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsRestClient.java index c1b971141..25f4356f3 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsRestClient.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/AzureDevopsRestClient.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021-2023 Michael Clarke + * Copyright (C) 2021-2024 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -23,6 +23,7 @@ import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CommentThreadResponse; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Commit; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Commits; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.ConnectionData; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CreateCommentRequest; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CreateCommentThreadRequest; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.GitPullRequestStatus; @@ -104,6 +105,13 @@ public void resolvePullRequestThread(String projectId, String repositoryName, in execute(url, "patch", objectMapper.writeValueAsString(commentThread), null); } + @Override + public void deletePullRequestThreadComment(String projectId, String repositoryName, int pullRequestId, int threadId, int commentId) throws IOException { + String url = String.format("%s/%s/_apis/git/repositories/%s/pullRequests/%s/threads/%s/comments/%s?api-version=%s", apiUrl, encode(projectId), encode(repositoryName), pullRequestId, threadId, commentId, API_VERSION); + + execute(url, "delete", null, null); + } + @Override public PullRequest retrievePullRequest(String projectId, String repositoryName, int pullRequestId) throws IOException { String url = String.format("%s/%s/_apis/git/repositories/%s/pullRequests/%s?api-version=%s", apiUrl, encode(projectId), encode(repositoryName), pullRequestId, API_VERSION); @@ -116,6 +124,12 @@ public List getPullRequestCommits(String projectId, String repositoryNam return Objects.requireNonNull(execute(url, "get", null, Commits.class)).getValue(); } + @Override + public ConnectionData getConnectionData() throws IOException { + String url = String.format("%s/_apis/ConnectionData?api-version=%s", apiUrl, API_VERSION); + return Objects.requireNonNull(execute(url, "get", null, ConnectionData.class)); + } + private T execute(String url, String method, String content, Class type) throws IOException { RequestBuilder requestBuilder = RequestBuilder.create(method) diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/Comment.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/Comment.java index 4739f48de..063abd158 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/Comment.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/Comment.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2020-2024 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.almclient.azuredevops.model; import com.fasterxml.jackson.annotation.JsonCreator; @@ -10,21 +28,24 @@ */ public class Comment { + private final int id; private final String content; private final IdentityRef author; private final CommentType commentType; @JsonCreator - public Comment(@JsonProperty("content") String content, @JsonProperty("author") IdentityRef author, + public Comment(@JsonProperty("id") int id, @JsonProperty("content") String content, @JsonProperty("author") IdentityRef author, @JsonProperty("commentType") CommentType commentType) { + this.id = id; this.content = content; this.author = author; this.commentType = commentType; } - /** - * The comment content. - */ + public int getId() { + return id; + } + public String getContent() { return this.content; } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/ConnectionData.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/ConnectionData.java new file mode 100644 index 000000000..137df7f16 --- /dev/null +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/azuredevops/model/ConnectionData.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 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.almclient.azuredevops.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ConnectionData { + + private final Identity authenticatedUser; + + public ConnectionData(@JsonProperty("authenticatedUser") Identity authenticatedUser) { + this.authenticatedUser = authenticatedUser; + } + + public Identity getAuthenticatedUser() { + return authenticatedUser; + } + + public static class Identity { + + private final String id; + + public Identity(@JsonProperty("id") String id) { + this.id = id; + } + + public String getId() { + return id; + } + } + +} diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabClient.java index dd2937c06..7b68ce286 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabClient.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabClient.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 Michael Clarke + * Copyright (C) 2021-2024 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -48,4 +48,6 @@ public interface GitlabClient { void setMergeRequestPipelineStatus(long projectId, String commitRevision, PipelineStatus status) throws IOException; Project getProject(String projectSlug) throws IOException; + + void deleteMergeRequestDiscussionNote(long projectId, long mergeRequestIid, String discussionId, long noteId) throws IOException; } diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabRestClient.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabRestClient.java index cc2a0997e..6ecf5c872 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabRestClient.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/almclient/gitlab/GitlabRestClient.java @@ -30,6 +30,7 @@ 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; @@ -133,6 +134,14 @@ public void resolveMergeRequestDiscussion(long projectId, long mergeRequestIid, entity(httpPut, null); } + @Override + public void deleteMergeRequestDiscussionNote(long projectId, long mergeRequestIid, String discussionId, long noteId) throws IOException { + String discussionIdUrl = String.format("%s/projects/%s/merge_requests/%s/discussions/%s/notes/%s", baseGitlabApiUrl, projectId, mergeRequestIid, discussionId, noteId); + + HttpDelete httpDelete = new HttpDelete(discussionIdUrl); + entity(httpDelete, null, x -> validateResponse(x, 204, "Commit discussions note deleted")); + } + @Override public void setMergeRequestPipelineStatus(long projectId, String commitRevision, PipelineStatus status) throws IOException { List entityFields = new ArrayList<>(Arrays.asList( diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DiscussionAwarePullRequestDecorator.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DiscussionAwarePullRequestDecorator.java index 40687e623..e9f204e89 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DiscussionAwarePullRequestDecorator.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/DiscussionAwarePullRequestDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021-2022 Michael Clarke + * Copyright (C) 2021-2024 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -49,9 +49,13 @@ public abstract class DiscussionAwarePullRequestDecorator impleme 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 String RESOLVED_SUMMARY_NEEDING_CLOSED_MESSAGE = + "This summary note is outdated, but due to other comments being present in this discussion, the discussion is not being being removed. " + + "Please manually resolve this discussion once the other comments have been reviewed."; private static final String VIEW_IN_SONARQUBE_LABEL = "View in SonarQube"; private static final Pattern NOTE_MARKDOWN_VIEW_LINK_PATTERN = Pattern.compile("^\\[" + VIEW_IN_SONARQUBE_LABEL + "]\\((.*?)\\)$"); + private static final String DECORATOR_SUMMARY_COMMENT = "decorator-summary-comment"; private final ScmInfoRepository scmInfoRepository; private final ReportGenerator reportGenerator; @@ -137,6 +141,8 @@ protected abstract void submitCommitNoteForIssue(C client, P pullRequest, PostAn protected abstract void resolveDiscussion(C client, D discussion, P pullRequest); + protected abstract void deleteDiscussion(C client, D discussion, P pullRequest, List notesForDiscussion); + protected abstract void submitSummaryNote(C client, P pullRequest, AnalysisDetails analysis, AnalysisSummary analysisSummary); protected abstract List getDiscussions(C client, P pullRequest); @@ -205,7 +211,9 @@ private List closeOldDiscussionsAndExtractRemainingKeys(C client, U curr } String issueKey = noteIdentifier.get().getIssueKey(); - if (!openIssueKeys.contains(issueKey)) { + if (DECORATOR_SUMMARY_COMMENT.equals(issueKey)) { + deleteOrPlaceFinalCommentOnDiscussion(client, currentUser, discussion, pullRequest); + } else if (!openIssueKeys.contains(issueKey)) { resolveOrPlaceFinalCommentOnDiscussion(client, currentUser, discussion, pullRequest); } else { remainingCommentKeys.add(issueKey); @@ -218,7 +226,8 @@ private List closeOldDiscussionsAndExtractRemainingKeys(C client, U curr private boolean isResolved(C client, D discussion, List notesInDiscussion, U currentUser) { return isClosed(discussion, notesInDiscussion) || notesInDiscussion.stream() .filter(message -> isNoteFromCurrentUser(message, currentUser)) - .anyMatch(message -> RESOLVED_ISSUE_NEEDING_CLOSED_MESSAGE.equals(getNoteContent(client, message))); + .map(message -> getNoteContent(client, message)) + .anyMatch(content -> RESOLVED_ISSUE_NEEDING_CLOSED_MESSAGE.equals(content) || RESOLVED_SUMMARY_NEEDING_CLOSED_MESSAGE.equals(content)); } private void resolveOrPlaceFinalCommentOnDiscussion(C client, U currentUser, D discussion, P pullRequest) { @@ -232,6 +241,18 @@ private void resolveOrPlaceFinalCommentOnDiscussion(C client, U currentUser, D d } + private void deleteOrPlaceFinalCommentOnDiscussion(C client, U currentUser, D discussion, P pullRequest) { + List notesForDiscussion = getNotesForDiscussion(client, discussion); + if (notesForDiscussion.stream() + .filter(this::isUserNote) + .anyMatch(note -> !isNoteFromCurrentUser(note, currentUser))) { + addNoteToDiscussion(client, discussion, pullRequest, RESOLVED_SUMMARY_NEEDING_CLOSED_MESSAGE); + } else { + deleteDiscussion(client, discussion, pullRequest, notesForDiscussion); + } + + } + protected Optional parseIssueDetails(C client, N note) { return parseIssueDetails(client, note, VIEW_IN_SONARQUBE_LABEL, NOTE_MARKDOWN_VIEW_LINK_PATTERN); } @@ -274,7 +295,7 @@ private static Optional parseIssueIdFromUrl(String issue String projectId = optionalProjectId.get(); if (url.getPath().endsWith("/dashboard")) { - return Optional.of(new ProjectIssueIdentifier(projectId, "decorator-summary-comment")); + return Optional.of(new ProjectIssueIdentifier(projectId, DECORATOR_SUMMARY_COMMENT)); } else if (url.getPath().endsWith("security_hotspots")) { return parameters.stream() .filter(parameter -> "hotspots".equals(parameter.getName())) diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecorator.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecorator.java index 6ffe5ca59..4df7b1d3a 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecorator.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2023 Markus Heberling, Michael Clarke + * Copyright (C) 2020-2024 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 @@ -18,6 +18,25 @@ */ package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.azuredevops; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.ce.posttask.QualityGate; +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 org.sonar.db.protobuf.DbIssues; + import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.AzureDevopsClient; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.AzureDevopsClientFactory; import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Comment; @@ -41,26 +60,8 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; -import org.apache.commons.lang.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.sonar.api.ce.posttask.QualityGate; -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 org.sonar.db.protobuf.DbIssues; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -public class AzureDevOpsPullRequestDecorator extends DiscussionAwarePullRequestDecorator implements PullRequestBuildStatusDecorator { +public class AzureDevOpsPullRequestDecorator extends DiscussionAwarePullRequestDecorator implements PullRequestBuildStatusDecorator { private static final Logger logger = LoggerFactory.getLogger(AzureDevOpsPullRequestDecorator.class); private static final Pattern NOTE_MARKDOWN_LEGACY_SEE_LINK_PATTERN = Pattern.compile("^\\[See in SonarQube]\\((.*?)\\)$"); @@ -115,8 +116,14 @@ protected PullRequest getPullRequest(AzureDevopsClient client, AlmSettingDto alm } @Override - protected Void getCurrentUser(AzureDevopsClient client) { - return null; + protected String getCurrentUser(AzureDevopsClient client) { + try { + return client.getConnectionData().getAuthenticatedUser().getId(); + } catch (Exception e) { + logger.warn("Could not retrieve authenticated user", e); + // historically we didn't handle users here so always returned null. This is a fallback to that behaviour. + return null; + } } @Override @@ -194,8 +201,8 @@ protected List getDiscussions(AzureDevopsClient client, PullReque } @Override - protected boolean isNoteFromCurrentUser(Comment note, Void user) { - return true; + protected boolean isNoteFromCurrentUser(Comment note, String user) { + return note.getAuthor().getId().equals(user); } @Override @@ -236,6 +243,17 @@ protected void resolveDiscussion(AzureDevopsClient client, CommentThread discuss } } + @Override + protected void deleteDiscussion(AzureDevopsClient client, CommentThread discussion, PullRequest pullRequest, List notesForDiscussion) { + try { + for (Comment note : notesForDiscussion) { + client.deletePullRequestThreadComment(pullRequest.getRepository().getProject().getName(), pullRequest.getRepository().getName(), pullRequest.getId(), discussion.getId(), note.getId()); + } + } catch (IOException ex) { + throw new IllegalStateException("Could not delete Pull Request comment thread on Azure Devops", ex); + } + } + @Override protected Optional parseIssueDetails(AzureDevopsClient client, Comment note) { Optional issueIdentifier = super.parseIssueDetails(client, note); 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 index 37a22f6fc..8e7205088 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2022 Markus Heberling, Michael Clarke + * Copyright (C) 2020-2024 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 @@ -224,4 +224,15 @@ protected void resolveDiscussion(GitlabClient client, Discussion discussion, Mer } } + @Override + protected void deleteDiscussion(GitlabClient client, Discussion discussion, MergeRequest pullRequest, List notesForDiscussion) { + try { + for (Note note : notesForDiscussion) { + client.deleteMergeRequestDiscussionNote(pullRequest.getTargetProjectId(), pullRequest.getIid(), discussion.getId(), note.getId()); + } + } catch (IOException ex) { + throw new IllegalStateException("Could not delete Merge Request discussion", ex); + } + } + } diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorIntegrationTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorIntegrationTest.java new file mode 100644 index 000000000..eef69b814 --- /dev/null +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorIntegrationTest.java @@ -0,0 +1,559 @@ +/* + * Copyright (C) 2024 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.azuredevops; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +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.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +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; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.sonar.api.ce.posttask.QualityGate; +import org.sonar.api.config.internal.Encryption; +import org.sonar.api.config.internal.Settings; +import org.sonar.api.issue.Issue; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.rules.RuleType; +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 org.sonar.db.protobuf.DbIssues; + +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.DefaultAzureDevopsClientFactory; +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.markup.MarkdownFormatterFactory; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; + +class AzureDevOpsPullRequestDecoratorIntegrationTest { + + @RegisterExtension + static final WireMockExtension wireMockExtension = WireMockExtension.newInstance() + .failOnUnmatchedRequests(true) + .build(); + + private final String azureProject = "azure Project"; + private final String sonarProject = "sonarProject"; + private final int pullRequestId = 8513; + private final String azureRepository = "my Repository"; + private final String sonarRootUrl = "http://sonar:9000/sonar"; + private final String filePath = "path/to/file"; + private final String issueMessage = "issueMessage"; + private final String issueKeyVal = "issueKeyVal"; + private final String ruleKeyVal = "ruleKeyVal"; + private final String threadId = "1468"; + private final int lineNumber = 5; + private final String token = "token"; + private final String authHeader = "Basic OnRva2Vu"; + private final String authorId = "author-id"; + private final String projectName = "Project Name"; + + private final ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); + private final AlmSettingDto almSettingDto = mock(AlmSettingDto.class); + private final ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); + private final Settings settings = mock(Settings.class); + private final Encryption encryption = mock(Encryption.class); + private final ReportGenerator reportGenerator = mock(ReportGenerator.class); + private final MarkdownFormatterFactory formatterFactory = mock(MarkdownFormatterFactory.class); + private final AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, new DefaultAzureDevopsClientFactory(settings), reportGenerator, formatterFactory); + private final AnalysisDetails analysisDetails = mock(AnalysisDetails.class); + + private final PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); + private final PostAnalysisIssueVisitor.LightIssue defaultIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); + private final Component component = mock(Component.class); + + @BeforeEach + void setUp() { + when(settings.getEncryption()).thenReturn(encryption); + when(reportGenerator.createAnalysisIssueSummary(any(), any())).thenReturn(mock(AnalysisIssueSummary.class)); + when(reportGenerator.createAnalysisSummary(any())).thenReturn(mock(AnalysisSummary.class)); + } + + private void configureTestDefaults() { + when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn(token); + when(almSettingDto.getUrl()).thenReturn(wireMockExtension.baseUrl()); + + when(analysisDetails.getAnalysisProjectName()).thenReturn(projectName); + when(analysisDetails.getAnalysisProjectKey()).thenReturn(sonarProject); + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); + when(analysisDetails.getPullRequestId()).thenReturn(Integer.toString(pullRequestId)); + when(analysisDetails.getIssues()).thenReturn(List.of(componentIssue)); + + AnalysisSummary analysisSummary = mock(AnalysisSummary.class); + when(analysisSummary.format(any())).thenReturn("analysis summary"); + when(analysisSummary.getDashboardUrl()).thenReturn("http://sonar:9000/sonar/dashboard?id=" + sonarProject + "&pullRequest=" + pullRequestId); + AnalysisIssueSummary analysisIssueSummary = mock(AnalysisIssueSummary.class); + + when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); + when(reportGenerator.createAnalysisIssueSummary(any(), any())).thenReturn(analysisIssueSummary); + + DbIssues.Locations locate = DbIssues.Locations.newBuilder().build(); + RuleType rule = RuleType.CODE_SMELL; + RuleKey ruleKey = mock(RuleKey.class); + when(componentIssue.getIssue()).thenReturn(defaultIssue); + when(componentIssue.getComponent()).thenReturn(component); + when(componentIssue.getScmPath()).thenReturn(Optional.of("scmPath")); + when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); + when(defaultIssue.getLine()).thenReturn(lineNumber); + when(defaultIssue.getLocations()).thenReturn(locate); + when(defaultIssue.type()).thenReturn(rule); + when(defaultIssue.getMessage()).thenReturn(issueMessage); + when(defaultIssue.getRuleKey()).thenReturn(ruleKey); + when(defaultIssue.key()).thenReturn(issueKeyVal); + Changeset changeset = mock(Changeset.class); + when(changeset.getRevision()).thenReturn("revisionId"); + ScmInfo scmInfo = mock(ScmInfo.class); + when(scmInfo.hasChangesetForLine(anyInt())).thenReturn(true); + when(scmInfo.getChangesetForLine(anyInt())).thenReturn(changeset); + when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); + when(ruleKey.toString()).thenReturn(ruleKeyVal); + + when(projectAlmSettingDto.getAlmSlug()).thenReturn(azureProject); + when(projectAlmSettingDto.getAlmRepo()).thenReturn(azureRepository); + + setupStubs(); + } + + private void setupStubs() { + wireMockExtension.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Authorization", equalTo(authHeader)) + .willReturn(aResponse() + .withStatus(200) + .withBody( + "{" + System.lineSeparator() + + " \"value\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"pullRequestThreadContext\": {" + System.lineSeparator() + + " \"iterationContext\": {" + System.lineSeparator() + + " \"firstComparingIteration\": 1," + System.lineSeparator() + + " \"secondComparingIteration\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changeTrackingId\": 4" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": " + threadId + "," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"comments\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"id\": 1," + System.lineSeparator() + + " \"parentCommentId\": 0," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"displayName\": \"More text\"," + System.lineSeparator() + + " \"url\": \"" + wireMockExtension.baseUrl() + "/fabrikam/_apis/Identities/c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"avatar\": {" + System.lineSeparator() + + " \"href\": \"" + wireMockExtension.baseUrl() + "/fabrikam/_apis/GraphProfile/MemberAvatars/win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": \"" + authorId + "\"," + System.lineSeparator() + + " \"uniqueName\": \"user@mail.ru\"," + System.lineSeparator() + + " \"imageUrl\": \"" + wireMockExtension.baseUrl() + "/fabrikam/_api/_common/identityImage?id=c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + + " \"descriptor\": \"win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"content\": \"CODE_SMELL: Remove this unnecessary 'using'. \\n[View in SonarQube](" + wireMockExtension.baseUrl() + "/coding_rules?open=" + issueKeyVal + "&rule_key=" + issueKeyVal + ")\"," + System.lineSeparator() + + " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"lastContentUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"commentType\": \"text\"," + System.lineSeparator() + + " \"usersLiked\": []," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"" + wireMockExtension.baseUrl() + "/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450/comments/1\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"threadContext\": {" + System.lineSeparator() + + " \"filePath\": \"/" + filePath +"\"," + System.lineSeparator() + + " \"rightFileStart\": {" + System.lineSeparator() + + " \"line\": 18," + System.lineSeparator() + + " \"offset\": 11" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"rightFileEnd\": {" + System.lineSeparator() + + " \"line\": 18," + System.lineSeparator() + + " \"offset\": 15" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"properties\": {}," + System.lineSeparator() + + " \"identities\": null," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"" + wireMockExtension.baseUrl() + "/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"count\": 2" + System.lineSeparator() + + "}"))); + + wireMockExtension.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId +"?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Authorization", equalTo(authHeader)) + .willReturn(aResponse() + .withStatus(200) + .withBody("{" + System.lineSeparator() + + " \"repository\": {" + System.lineSeparator() + + " \"id\": \"3411ebc1-d5aa-464f-9615-0b527bc66719\"," + System.lineSeparator() + + " \"name\": \"" + azureRepository + "\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"," + System.lineSeparator() + + " \"project\": {" + System.lineSeparator() + + " \"id\": \"a7573007-bbb3-4341-b726-0c4148a07853\"," + System.lineSeparator() + + " \"name\": \"" + azureProject + "\"," + System.lineSeparator() + + " \"description\": \"test project created on Halloween 2016\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853\"," + System.lineSeparator() + + " \"state\": \"wellFormed\"," + System.lineSeparator() + + " \"revision\": 7" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"remoteUrl\": \"" + wireMockExtension.baseUrl() + "/" + azureProject + "/_git/" + azureRepository + "\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"pullRequestId\": " + pullRequestId + "," + System.lineSeparator() + + " \"codeReviewId\": " + pullRequestId + "," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"createdBy\": {" + System.lineSeparator() + + " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + + " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"creationDate\": \"2016-11-01T16:30:31.6655471Z\"," + System.lineSeparator() + + " \"title\": \"A new feature\"," + System.lineSeparator() + + " \"description\": \"Adding a new feature\"," + System.lineSeparator() + + " \"sourceRefName\": \"refs/heads/npaulk/my_work\"," + System.lineSeparator() + + " \"targetRefName\": \"refs/heads/new_feature\"," + System.lineSeparator() + + " \"mergeStatus\": \"succeeded\"," + System.lineSeparator() + + " \"mergeId\": \"f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82\"," + System.lineSeparator() + + " \"lastMergeSourceCommit\": {" + System.lineSeparator() + + " \"commitId\": \"b60280bc6e62e2f880f1b63c1e24987664d3bda3\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"lastMergeTargetCommit\": {" + System.lineSeparator() + + " \"commitId\": \"f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"lastMergeCommit\": {" + System.lineSeparator() + + " \"commitId\": \"39f52d24533cc712fc845ed9fd1b6c06b3942588\"," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"name\": \"Normal Paulk\"," + System.lineSeparator() + + " \"email\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"date\": \"2016-11-01T16:30:32Z\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"committer\": {" + System.lineSeparator() + + " \"name\": \"Normal Paulk\"," + System.lineSeparator() + + " \"email\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"date\": \"2016-11-01T16:30:32Z\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"comment\": \"Merge pull request 22 from npaulk/my_work into new_feature\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"reviewers\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"reviewerUrl\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"vote\": 0," + System.lineSeparator() + + " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + + " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22\"," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"repository\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"workItems\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"sourceBranch\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"targetBranch\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"sourceCommit\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"targetCommit\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"createdBy\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"iterations\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"supportsIterations\": true," + System.lineSeparator() + + " \"artifactId\": \"vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22\"" + System.lineSeparator() + + "}"))); + + wireMockExtension.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/threads/" + threadId + "/comments?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(authHeader)) + .withRequestBody(equalTo("{\"content\":\"Issue has been closed in SonarQube\"}") + ) + .willReturn(ok())); + + wireMockExtension.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/commits?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Authorization", equalTo(authHeader)) + .willReturn(aResponse().withStatus(200).withBody("{\"value\": [{" + System.lineSeparator() + + " \"parents\": []," + System.lineSeparator() + + " \"treeId\": \"7fa1a3523ffef51c525ea476bffff7d648b8cb3d\"," + System.lineSeparator() + + " \"push\": {" + System.lineSeparator() + + " \"pushedBy\": {" + System.lineSeparator() + + " \"id\": \"8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"," + System.lineSeparator() + + " \"displayName\": \"Chuck Reinhart\"," + System.lineSeparator() + + " \"uniqueName\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + + " \"url\": \"https://vssps.dev.azure.com/fabrikam/_apis/Identities/8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"pushId\": 1," + System.lineSeparator() + + " \"date\": \"2014-01-29T23:33:15.2434002Z\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"commitId\": \"revisionId\"," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"name\": \"Chuck Reinhart\"," + System.lineSeparator() + + " \"email\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + + " \"date\": \"2014-01-29T23:32:09Z\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"committer\": {" + System.lineSeparator() + + " \"name\": \"Chuck Reinhart\"," + System.lineSeparator() + + " \"email\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + + " \"date\": \"2014-01-29T23:32:09Z\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"comment\": \"First cut\\n\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"," + System.lineSeparator() + + " \"remoteUrl\": \"https://dev.azure.com/fabrikam/_git/Fabrikam-Fiber-Git/commit/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"repository\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changes\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4/changes\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"web\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_git/Fabrikam-Fiber-Git/commit/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"tree\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/trees/7fa1a3523ffef51c525ea476bffff7d648b8cb3d\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + "}]}"))); + + + wireMockExtension.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/statuses?api-version=4.1-preview")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(authHeader)) + .withRequestBody(equalTo("{" + + "\"state\":\"SUCCEEDED\"," + + "\"description\":\"SonarQube Quality Gate - " + projectName + " (" + sonarProject + ")\"," + + "\"context\":{\"genre\":\"sonarqube/qualitygate\",\"name\":\"" + sonarProject + "\"}," + + "\"targetUrl\":\"" + sonarRootUrl + "/dashboard?id=" + sonarProject + "&pullRequest=" + pullRequestId + "\"" + + "}") + ) + .willReturn(ok())); + + wireMockExtension.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(authHeader)) + .withRequestBody(equalTo("{\"comments\":[{\"content\":\"analysis summary\"}],\"status\":\"active\"}")) + .willReturn(aResponse().withStatus(200).withBody("{" + System.lineSeparator() + + " \"pullRequestThreadContext\": {" + System.lineSeparator() + + " \"iterationContext\": {" + System.lineSeparator() + + " \"firstComparingIteration\": 1," + System.lineSeparator() + + " \"secondComparingIteration\": 2" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changeTrackingId\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": " + threadId + "," + System.lineSeparator() + + " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"comments\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"id\": 1," + System.lineSeparator() + + " \"parentCommentId\": 0," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + + " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"content\": \"Should we add a comment about what this value means?\"," + System.lineSeparator() + + " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"commentType\": \"text\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"threadContext\": {" + System.lineSeparator() + + " \"filePath\": \"/new_feature.cpp\"," + System.lineSeparator() + + " \"rightFileStart\": {" + System.lineSeparator() + + " \"line\": 5," + System.lineSeparator() + + " \"offset\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"rightFileEnd\": {" + System.lineSeparator() + + " \"line\": 5," + System.lineSeparator() + + " \"offset\": 13" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"properties\": {}," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/threads/148\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"repository\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + "}"))); + + wireMockExtension.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(authHeader)) + .withRequestBody(equalTo("{\"threadContext\":{\"filePath\":\"/scmPath\",\"rightFileStart\":{\"line\":0,\"offset\":1},\"rightFileEnd\":{\"line\":0,\"offset\":1}},\"comments\":[{\"content\":\"issue summary\"}],\"status\":\"active\"}")) + .willReturn(aResponse().withStatus(200).withBody("{" + System.lineSeparator() + + " \"pullRequestThreadContext\": {" + System.lineSeparator() + + " \"iterationContext\": {" + System.lineSeparator() + + " \"firstComparingIteration\": 1," + System.lineSeparator() + + " \"secondComparingIteration\": 2" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"changeTrackingId\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"id\": " + threadId + "," + System.lineSeparator() + + " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"comments\": [" + System.lineSeparator() + + " {" + System.lineSeparator() + + " \"id\": 1," + System.lineSeparator() + + " \"parentCommentId\": 0," + System.lineSeparator() + + " \"author\": {" + System.lineSeparator() + + " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + + " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + + " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"content\": \"Should we add a comment about what this value means?\"," + System.lineSeparator() + + " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + + " \"commentType\": \"text\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " ]," + System.lineSeparator() + + " \"status\": \"active\"," + System.lineSeparator() + + " \"threadContext\": {" + System.lineSeparator() + + " \"filePath\": \"/new_feature.cpp\"," + System.lineSeparator() + + " \"rightFileStart\": {" + System.lineSeparator() + + " \"line\": 5," + System.lineSeparator() + + " \"offset\": 1" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"rightFileEnd\": {" + System.lineSeparator() + + " \"line\": 5," + System.lineSeparator() + + " \"offset\": 13" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"properties\": {}," + System.lineSeparator() + + " \"isDeleted\": false," + System.lineSeparator() + + " \"_links\": {" + System.lineSeparator() + + " \"self\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/threads/148\"" + System.lineSeparator() + + " }," + System.lineSeparator() + + " \"repository\": {" + System.lineSeparator() + + " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + " }" + System.lineSeparator() + + "}"))); + + wireMockExtension.stubFor(patch(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/threads/" + threadId + "?api-version=4.1")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Authorization", equalTo(authHeader)) + .withRequestBody(equalTo("{" + + "\"status\":\"closed\"" + + "}") + ) + .willReturn(ok())); + + wireMockExtension.stubFor(get(urlEqualTo("/_apis/ConnectionData?api-version=4.1")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Authorization", equalTo(authHeader)) + .willReturn(aResponse().withStatus(200).withBody("{\"authenticatedUser\": {" + System.lineSeparator() + + " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + + " \"providerDisplayName\": \"Test User\"," + System.lineSeparator() + + " \"customDisplayName\": \"Test User\"," + System.lineSeparator() + + " \"emailAddress\": \"test.user@mail.domain\"" + System.lineSeparator() + + " }" + System.lineSeparator() + + "}"))); + } + + @Test + void decorateQualityGateStatusNewIssue() { + configureTestDefaults(); + + DecorationResult result = pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + assertThat(result.getPullRequestUrl()).isEqualTo(Optional.of(String.format("%s/%s/_git/%s/pullRequest/%s", wireMockExtension.getRuntimeInfo().getHttpBaseUrl(), azureProject, azureRepository, pullRequestId))); + } + + @Test + void decorateQualityGateStatusClosedIssue() { + configureTestDefaults(); + + when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_CLOSED); + when(defaultIssue.getLine()).thenReturn(18); + + DecorationResult result = pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + assertThat(result.getPullRequestUrl()).isEqualTo(Optional.of(String.format("%s/%s/_git/%s/pullRequest/%s", wireMockExtension.getRuntimeInfo().getHttpBaseUrl(), azureProject, azureRepository, pullRequestId))); + } + +} diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorTest.java index 0414bacc6..90f64e247 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/azuredevops/AzureDevOpsPullRequestDecoratorTest.java @@ -1,609 +1,342 @@ +/* + * Copyright (C) 2024 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.azuredevops; -import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.AzureDevopsClientFactory; -import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.DefaultAzureDevopsClientFactory; -import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.PullRequest; -import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Repository; -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.markup.MarkdownFormatterFactory; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; -import com.github.tomakehurst.wiremock.junit.WireMockRule; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.sonar.api.ce.posttask.QualityGate; -import org.sonar.api.config.internal.Encryption; -import org.sonar.api.config.internal.Settings; -import org.sonar.api.issue.Issue; -import org.sonar.api.rule.RuleKey; -import org.sonar.api.rules.RuleType; -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 org.sonar.db.protobuf.DbIssues; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -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.patch; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; 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.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; +import java.util.Collections; +import java.util.List; -public class AzureDevOpsPullRequestDecoratorTest { - - @Rule - public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort()); - - private final String azureProject = "azure Project"; - private final String sonarProject = "sonarProject"; - private final String pullRequestId = "8513"; - private final String azureRepository = "my Repository"; - private final String sonarRootUrl = "http://sonar:9000/sonar"; - private final String filePath = "path/to/file"; - private final String issueMessage = "issueMessage"; - private final String issueKeyVal = "issueKeyVal"; - private final String ruleKeyVal = "ruleKeyVal"; - private final String threadId = "1468"; - private final int lineNumber = 5; - private final String token = "token"; - private final String authHeader = "Basic OnRva2Vu"; - private final String authorId = "author-id"; - private final String projectName = "Project Name"; - - private final ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); - private final AlmSettingDto almSettingDto = mock(AlmSettingDto.class); - private final ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); - private final Settings settings = mock(Settings.class); - private final Encryption encryption = mock(Encryption.class); - private final ReportGenerator reportGenerator = mock(ReportGenerator.class); - private final MarkdownFormatterFactory formatterFactory = mock(MarkdownFormatterFactory.class); - private final AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, new DefaultAzureDevopsClientFactory(settings), reportGenerator, formatterFactory); - private final AnalysisDetails analysisDetails = mock(AnalysisDetails.class); - - private final PostAnalysisIssueVisitor.ComponentIssue componentIssue = mock(PostAnalysisIssueVisitor.ComponentIssue.class); - private final PostAnalysisIssueVisitor.LightIssue defaultIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); - private final Component component = mock(Component.class); - - @Before - public void setUp() { - when(settings.getEncryption()).thenReturn(encryption); - when(reportGenerator.createAnalysisIssueSummary(any(), any())).thenReturn(mock(AnalysisIssueSummary.class)); - when(reportGenerator.createAnalysisSummary(any())).thenReturn(mock(AnalysisSummary.class)); - } - - private void configureTestDefaults() { - when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn(token); - when(almSettingDto.getUrl()).thenReturn(wireMockRule.baseUrl()); - - when(analysisDetails.getAnalysisProjectName()).thenReturn(projectName); - when(analysisDetails.getAnalysisProjectKey()).thenReturn(sonarProject); - when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); - when(analysisDetails.getPullRequestId()).thenReturn(pullRequestId); - when(analysisDetails.getIssues()).thenReturn(List.of(componentIssue)); - - AnalysisSummary analysisSummary = mock(AnalysisSummary.class); - when(analysisSummary.format(any())).thenReturn("analysis summary"); - when(analysisSummary.getDashboardUrl()).thenReturn("http://sonar:9000/sonar/dashboard?id=" + sonarProject + "&pullRequest=" + pullRequestId); - AnalysisIssueSummary analysisIssueSummary = mock(AnalysisIssueSummary.class); - - when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); - when(reportGenerator.createAnalysisIssueSummary(any(), any())).thenReturn(analysisIssueSummary); - - DbIssues.Locations locate = DbIssues.Locations.newBuilder().build(); - RuleType rule = RuleType.CODE_SMELL; - RuleKey ruleKey = mock(RuleKey.class); - when(componentIssue.getIssue()).thenReturn(defaultIssue); - when(componentIssue.getComponent()).thenReturn(component); - when(componentIssue.getScmPath()).thenReturn(Optional.of("scmPath")); - when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); - when(defaultIssue.getLine()).thenReturn(lineNumber); - when(defaultIssue.getLocations()).thenReturn(locate); - when(defaultIssue.type()).thenReturn(rule); - when(defaultIssue.getMessage()).thenReturn(issueMessage); - when(defaultIssue.getRuleKey()).thenReturn(ruleKey); - when(defaultIssue.key()).thenReturn(issueKeyVal); - Changeset changeset = mock(Changeset.class); - when(changeset.getRevision()).thenReturn("revisionId"); - ScmInfo scmInfo = mock(ScmInfo.class); - when(scmInfo.hasChangesetForLine(anyInt())).thenReturn(true); - when(scmInfo.getChangesetForLine(anyInt())).thenReturn(changeset); - when(scmInfoRepository.getScmInfo(component)).thenReturn(Optional.of(scmInfo)); - when(ruleKey.toString()).thenReturn(ruleKeyVal); +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.sonar.api.ce.posttask.QualityGate; +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; - when(projectAlmSettingDto.getAlmSlug()).thenReturn(azureProject); - when(projectAlmSettingDto.getAlmRepo()).thenReturn(azureRepository); +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.AzureDevopsClient; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.AzureDevopsClientFactory; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Comment; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CommentThread; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.ConnectionData; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.CreateCommentRequest; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.IdentityRef; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Project; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.PullRequest; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.Repository; +import com.github.mc1arke.sonarqube.plugin.almclient.azuredevops.model.enums.CommentType; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.MarkdownFormatterFactory; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; - setupStubs(); - } +class AzureDevOpsPullRequestDecoratorTest { - private void setupStubs() { - wireMockRule.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Authorization", equalTo(authHeader)) - .willReturn(aResponse() - .withStatus(200) - .withBody( - "{" + System.lineSeparator() + - " \"value\": [" + System.lineSeparator() + - " {" + System.lineSeparator() + - " \"pullRequestThreadContext\": {" + System.lineSeparator() + - " \"iterationContext\": {" + System.lineSeparator() + - " \"firstComparingIteration\": 1," + System.lineSeparator() + - " \"secondComparingIteration\": 1" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"changeTrackingId\": 4" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"id\": " + threadId + "," + System.lineSeparator() + - " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + - " \"comments\": [" + System.lineSeparator() + - " {" + System.lineSeparator() + - " \"id\": 1," + System.lineSeparator() + - " \"parentCommentId\": 0," + System.lineSeparator() + - " \"author\": {" + System.lineSeparator() + - " \"displayName\": \"More text\"," + System.lineSeparator() + - " \"url\": \"" + wireMockRule.baseUrl() + "/fabrikam/_apis/Identities/c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"avatar\": {" + System.lineSeparator() + - " \"href\": \"" + wireMockRule.baseUrl() + "/fabrikam/_apis/GraphProfile/MemberAvatars/win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"id\": \"" + authorId + "\"," + System.lineSeparator() + - " \"uniqueName\": \"user@mail.ru\"," + System.lineSeparator() + - " \"imageUrl\": \"" + wireMockRule.baseUrl() + "/fabrikam/_api/_common/identityImage?id=c27db56f-07a0-43ac-9725-d6666e8b66b5\"," + System.lineSeparator() + - " \"descriptor\": \"win.Uy0xLTUtMjEtMzkwNzU4MjE0NC0yNDM3MzcyODg4LTE5Njg5NDAzMjgtMjIxNQ\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"content\": \"CODE_SMELL: Remove this unnecessary 'using'. \\n[View in SonarQube](" + wireMockRule.baseUrl() + "/coding_rules?open=" + issueKeyVal + "&rule_key=" + issueKeyVal + ")\"," + System.lineSeparator() + - " \"publishedDate\": \"2020-03-10T17:40:09.603Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + - " \"lastContentUpdatedDate\": \"2020-03-10T18:05:06.99Z\"," + System.lineSeparator() + - " \"isDeleted\": false," + System.lineSeparator() + - " \"commentType\": \"text\"," + System.lineSeparator() + - " \"usersLiked\": []," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"" + wireMockRule.baseUrl() + "/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450/comments/1\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - " ]," + System.lineSeparator() + - " \"status\": \"active\"," + System.lineSeparator() + - " \"threadContext\": {" + System.lineSeparator() + - " \"filePath\": \"/" + filePath +"\"," + System.lineSeparator() + - " \"rightFileStart\": {" + System.lineSeparator() + - " \"line\": 18," + System.lineSeparator() + - " \"offset\": 11" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"rightFileEnd\": {" + System.lineSeparator() + - " \"line\": 18," + System.lineSeparator() + - " \"offset\": 15" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"properties\": {}," + System.lineSeparator() + - " \"identities\": null," + System.lineSeparator() + - " \"isDeleted\": false," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"" + wireMockRule.baseUrl() + "/fabrikam/_apis/git/repositories/28afee9d-4e53-46b8-8deb-99ea20202b2b/pullRequests/8513/threads/80450\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - " ]," + System.lineSeparator() + - " \"count\": 2" + System.lineSeparator() + - "}"))); - - wireMockRule.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId +"?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Authorization", equalTo(authHeader)) - .willReturn(aResponse() - .withStatus(200) - .withBody("{" + System.lineSeparator() + - " \"repository\": {" + System.lineSeparator() + - " \"id\": \"3411ebc1-d5aa-464f-9615-0b527bc66719\"," + System.lineSeparator() + - " \"name\": \"" + azureRepository + "\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"," + System.lineSeparator() + - " \"project\": {" + System.lineSeparator() + - " \"id\": \"a7573007-bbb3-4341-b726-0c4148a07853\"," + System.lineSeparator() + - " \"name\": \"" + azureProject + "\"," + System.lineSeparator() + - " \"description\": \"test project created on Halloween 2016\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853\"," + System.lineSeparator() + - " \"state\": \"wellFormed\"," + System.lineSeparator() + - " \"revision\": 7" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"remoteUrl\": \"" + wireMockRule.baseUrl() + "/" + azureProject + "/_git/" + azureRepository + "\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"pullRequestId\": " + pullRequestId + "," + System.lineSeparator() + - " \"codeReviewId\": " + pullRequestId + "," + System.lineSeparator() + - " \"status\": \"active\"," + System.lineSeparator() + - " \"createdBy\": {" + System.lineSeparator() + - " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + - " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"creationDate\": \"2016-11-01T16:30:31.6655471Z\"," + System.lineSeparator() + - " \"title\": \"A new feature\"," + System.lineSeparator() + - " \"description\": \"Adding a new feature\"," + System.lineSeparator() + - " \"sourceRefName\": \"refs/heads/npaulk/my_work\"," + System.lineSeparator() + - " \"targetRefName\": \"refs/heads/new_feature\"," + System.lineSeparator() + - " \"mergeStatus\": \"succeeded\"," + System.lineSeparator() + - " \"mergeId\": \"f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82\"," + System.lineSeparator() + - " \"lastMergeSourceCommit\": {" + System.lineSeparator() + - " \"commitId\": \"b60280bc6e62e2f880f1b63c1e24987664d3bda3\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"lastMergeTargetCommit\": {" + System.lineSeparator() + - " \"commitId\": \"f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"lastMergeCommit\": {" + System.lineSeparator() + - " \"commitId\": \"39f52d24533cc712fc845ed9fd1b6c06b3942588\"," + System.lineSeparator() + - " \"author\": {" + System.lineSeparator() + - " \"name\": \"Normal Paulk\"," + System.lineSeparator() + - " \"email\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"date\": \"2016-11-01T16:30:32Z\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"committer\": {" + System.lineSeparator() + - " \"name\": \"Normal Paulk\"," + System.lineSeparator() + - " \"email\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"date\": \"2016-11-01T16:30:32Z\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"comment\": \"Merge pull request 22 from npaulk/my_work into new_feature\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"reviewers\": [" + System.lineSeparator() + - " {" + System.lineSeparator() + - " \"reviewerUrl\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"vote\": 0," + System.lineSeparator() + - " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + - " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " ]," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22\"," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"repository\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"workItems\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"sourceBranch\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"targetBranch\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"sourceCommit\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"targetCommit\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"createdBy\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"iterations\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"supportsIterations\": true," + System.lineSeparator() + - " \"artifactId\": \"vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22\"" + System.lineSeparator() + - "}"))); - - wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/threads/" + threadId + "/comments?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) - .withHeader("Authorization", equalTo(authHeader)) - .withRequestBody(equalTo("{\"content\":\"Issue has been closed in SonarQube\"}") - ) - .willReturn(ok())); - - wireMockRule.stubFor(get(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/commits?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Authorization", equalTo(authHeader)) - .willReturn(aResponse().withStatus(200).withBody("{\"value\": [{" + System.lineSeparator() + - " \"parents\": []," + System.lineSeparator() + - " \"treeId\": \"7fa1a3523ffef51c525ea476bffff7d648b8cb3d\"," + System.lineSeparator() + - " \"push\": {" + System.lineSeparator() + - " \"pushedBy\": {" + System.lineSeparator() + - " \"id\": \"8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"," + System.lineSeparator() + - " \"displayName\": \"Chuck Reinhart\"," + System.lineSeparator() + - " \"uniqueName\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + - " \"url\": \"https://vssps.dev.azure.com/fabrikam/_apis/Identities/8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"," + System.lineSeparator() + - " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=8c8c7d32-6b1b-47f4-b2e9-30b477b5ab3d\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"pushId\": 1," + System.lineSeparator() + - " \"date\": \"2014-01-29T23:33:15.2434002Z\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"commitId\": \"revisionId\"," + System.lineSeparator() + - " \"author\": {" + System.lineSeparator() + - " \"name\": \"Chuck Reinhart\"," + System.lineSeparator() + - " \"email\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + - " \"date\": \"2014-01-29T23:32:09Z\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"committer\": {" + System.lineSeparator() + - " \"name\": \"Chuck Reinhart\"," + System.lineSeparator() + - " \"email\": \"fabrikamfiber3@hotmail.com\"," + System.lineSeparator() + - " \"date\": \"2014-01-29T23:32:09Z\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"comment\": \"First cut\\n\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"," + System.lineSeparator() + - " \"remoteUrl\": \"https://dev.azure.com/fabrikam/_git/Fabrikam-Fiber-Git/commit/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"repository\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"changes\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/commits/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4/changes\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"web\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_git/Fabrikam-Fiber-Git/commit/be67f8871a4d2c75f13a51c1d3c30ac0d74d4ef4\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"tree\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/trees/7fa1a3523ffef51c525ea476bffff7d648b8cb3d\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - "}]}"))); - - - wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/statuses?api-version=4.1-preview")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) - .withHeader("Authorization", equalTo(authHeader)) - .withRequestBody(equalTo("{" + - "\"state\":\"SUCCEEDED\"," + - "\"description\":\"SonarQube Quality Gate - " + projectName + " (" + sonarProject + ")\"," + - "\"context\":{\"genre\":\"sonarqube/qualitygate\",\"name\":\"" + sonarProject + "\"}," + - "\"targetUrl\":\"" + sonarRootUrl + "/dashboard?id=" + sonarProject + "&pullRequest=" + pullRequestId + "\"" + - "}") - ) - .willReturn(ok())); - - wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) - .withHeader("Authorization", equalTo(authHeader)) - .withRequestBody(equalTo("{\"comments\":[{\"content\":\"analysis summary\"}],\"status\":\"active\"}")) - .willReturn(aResponse().withStatus(200).withBody("{" + System.lineSeparator() + - " \"pullRequestThreadContext\": {" + System.lineSeparator() + - " \"iterationContext\": {" + System.lineSeparator() + - " \"firstComparingIteration\": 1," + System.lineSeparator() + - " \"secondComparingIteration\": 2" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"changeTrackingId\": 1" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"id\": " + threadId + "," + System.lineSeparator() + - " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"comments\": [" + System.lineSeparator() + - " {" + System.lineSeparator() + - " \"id\": 1," + System.lineSeparator() + - " \"parentCommentId\": 0," + System.lineSeparator() + - " \"author\": {" + System.lineSeparator() + - " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + - " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"content\": \"Should we add a comment about what this value means?\"," + System.lineSeparator() + - " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"commentType\": \"text\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " ]," + System.lineSeparator() + - " \"status\": \"active\"," + System.lineSeparator() + - " \"threadContext\": {" + System.lineSeparator() + - " \"filePath\": \"/new_feature.cpp\"," + System.lineSeparator() + - " \"rightFileStart\": {" + System.lineSeparator() + - " \"line\": 5," + System.lineSeparator() + - " \"offset\": 1" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"rightFileEnd\": {" + System.lineSeparator() + - " \"line\": 5," + System.lineSeparator() + - " \"offset\": 13" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"properties\": {}," + System.lineSeparator() + - " \"isDeleted\": false," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/threads/148\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"repository\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - "}"))); - - wireMockRule.stubFor(post(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/"+ pullRequestId +"/threads?api-version=4.1")) - .withHeader("Accept", equalTo("application/json")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) - .withHeader("Authorization", equalTo(authHeader)) - .withRequestBody(equalTo("{\"threadContext\":{\"filePath\":\"/scmPath\",\"rightFileStart\":{\"line\":0,\"offset\":1},\"rightFileEnd\":{\"line\":0,\"offset\":1}},\"comments\":[{\"content\":\"issue summary\"}],\"status\":\"active\"}")) - .willReturn(aResponse().withStatus(200).withBody("{" + System.lineSeparator() + - " \"pullRequestThreadContext\": {" + System.lineSeparator() + - " \"iterationContext\": {" + System.lineSeparator() + - " \"firstComparingIteration\": 1," + System.lineSeparator() + - " \"secondComparingIteration\": 2" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"changeTrackingId\": 1" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"id\": " + threadId + "," + System.lineSeparator() + - " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"comments\": [" + System.lineSeparator() + - " {" + System.lineSeparator() + - " \"id\": 1," + System.lineSeparator() + - " \"parentCommentId\": 0," + System.lineSeparator() + - " \"author\": {" + System.lineSeparator() + - " \"id\": \"d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"displayName\": \"Normal Paulk\"," + System.lineSeparator() + - " \"uniqueName\": \"fabrikamfiber16@hotmail.com\"," + System.lineSeparator() + - " \"url\": \"https://dev.azure.com/fabrikam/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db\"," + System.lineSeparator() + - " \"imageUrl\": \"https://dev.azure.com/fabrikam/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"content\": \"Should we add a comment about what this value means?\"," + System.lineSeparator() + - " \"publishedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"lastUpdatedDate\": \"2016-11-01T16:30:50.083Z\"," + System.lineSeparator() + - " \"commentType\": \"text\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " ]," + System.lineSeparator() + - " \"status\": \"active\"," + System.lineSeparator() + - " \"threadContext\": {" + System.lineSeparator() + - " \"filePath\": \"/new_feature.cpp\"," + System.lineSeparator() + - " \"rightFileStart\": {" + System.lineSeparator() + - " \"line\": 5," + System.lineSeparator() + - " \"offset\": 1" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"rightFileEnd\": {" + System.lineSeparator() + - " \"line\": 5," + System.lineSeparator() + - " \"offset\": 13" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"properties\": {}," + System.lineSeparator() + - " \"isDeleted\": false," + System.lineSeparator() + - " \"_links\": {" + System.lineSeparator() + - " \"self\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/threads/148\"" + System.lineSeparator() + - " }," + System.lineSeparator() + - " \"repository\": {" + System.lineSeparator() + - " \"href\": \"https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719\"" + System.lineSeparator() + - " }" + System.lineSeparator() + - " }" + System.lineSeparator() + - "}"))); - - wireMockRule.stubFor(patch(urlEqualTo("/azure%20Project/_apis/git/repositories/my%20Repository/pullRequests/" + pullRequestId + "/threads/" + threadId + "?api-version=4.1")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) - .withHeader("Authorization", equalTo(authHeader)) - .withRequestBody(equalTo("{" + - "\"status\":\"closed\"" + - "}") - ) - .willReturn(ok())); - } + private final AlmSettingDto almSettingDto = mock(); + private final ProjectAlmSettingDto projectAlmSettingDto = mock(); + private final AnalysisDetails analysisDetails = mock(); + private final ScmInfoRepository scmInfoRepository = mock(); + private final AzureDevopsClientFactory azureDevopsClientFactory = mock(); + private final ReportGenerator reportGenerator = mock(); + private final MarkdownFormatterFactory markdownFormatterFactory = mock(); - @Test - public void testName() { - assertThat(new AzureDevOpsPullRequestDecorator(mock(ScmInfoRepository.class), mock(AzureDevopsClientFactory.class), mock(ReportGenerator.class), mock(MarkdownFormatterFactory.class)).alm()).isEqualTo(Collections.singletonList(ALM.AZURE_DEVOPS)); - } @Test - public void testDecorateQualityGateRepoNameException() { + void testDecorateQualityGateRepoSlugException() { when(almSettingDto.getUrl()).thenReturn("almUrl"); when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn("personalAccessToken"); when(analysisDetails.getPullRequestId()).thenReturn("123"); - when(projectAlmSettingDto.getAlmSlug()).thenReturn("prj"); + when(projectAlmSettingDto.getAlmRepo()).thenReturn("repo"); + + AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) - .hasMessage("Repository name must be provided") - .isExactlyInstanceOf(IllegalStateException.class); + .hasMessage("Repository slug must be provided") + .isExactlyInstanceOf(IllegalStateException.class); } @Test - public void testDecorateQualityGateRepoSlugException() { + void testDecorateQualityGateProjectIDException() { when(almSettingDto.getUrl()).thenReturn("almUrl"); when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn("personalAccessToken"); - when(analysisDetails.getPullRequestId()).thenReturn("123"); when(projectAlmSettingDto.getAlmRepo()).thenReturn("repo"); + when(projectAlmSettingDto.getAlmSlug()).thenReturn("slug"); + + AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) - .hasMessage("Repository slug must be provided") - .isExactlyInstanceOf(IllegalStateException.class); + .hasMessage("Could not parse Pull Request Key") + .isExactlyInstanceOf(IllegalStateException.class); } @Test - public void testDecorateQualityGateProjectIDException() { + void testDecorateQualityGatePRBranchException() { when(almSettingDto.getUrl()).thenReturn("almUrl"); when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn("personalAccessToken"); + when(analysisDetails.getPullRequestId()).thenReturn("NON-NUMERIC"); + when(projectAlmSettingDto.getAlmSlug()).thenReturn("prj"); when(projectAlmSettingDto.getAlmRepo()).thenReturn("repo"); - when(projectAlmSettingDto.getAlmSlug()).thenReturn("slug"); + + AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) - .hasMessage("Could not parse Pull Request Key") - .isExactlyInstanceOf(IllegalStateException.class); + .hasMessage("Could not parse Pull Request Key") + .isExactlyInstanceOf(IllegalStateException.class); } @Test - public void testDecorateQualityGatePRBranchException() { + void shouldRemoveUserInfoFromRepositoryUrlForLinking() { + AzureDevOpsPullRequestDecorator underTest = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); + + Repository repository = mock(Repository.class); + when(repository.getRemoteUrl()).thenReturn("https://user@domain.com/path/to/repo"); + PullRequest pullRequest = mock(PullRequest.class); + when(pullRequest.getRepository()).thenReturn(repository); + when(pullRequest.getId()).thenReturn(999); + + assertThat(underTest.createFrontEndUrl(pullRequest, analysisDetails)).contains("https://domain.com/path/to/repo/pullRequest/999"); + } + + + @Test + void testName() { + assertThat(new AzureDevOpsPullRequestDecorator(mock(ScmInfoRepository.class), mock(AzureDevopsClientFactory.class), mock(ReportGenerator.class), mock(MarkdownFormatterFactory.class)).alm()).isEqualTo(Collections.singletonList(ALM.AZURE_DEVOPS)); + } + + @Test + void testDecorateQualityGateRepoNameException() { when(almSettingDto.getUrl()).thenReturn("almUrl"); when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn("personalAccessToken"); - when(analysisDetails.getPullRequestId()).thenReturn("NON-NUMERIC"); + when(analysisDetails.getPullRequestId()).thenReturn("123"); when(projectAlmSettingDto.getAlmSlug()).thenReturn("prj"); - when(projectAlmSettingDto.getAlmRepo()).thenReturn("repo"); + + AzureDevOpsPullRequestDecorator pullRequestDecorator = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); assertThatThrownBy(() -> pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) - .hasMessage("Could not parse Pull Request Key") - .isExactlyInstanceOf(IllegalStateException.class); + .hasMessage("Repository name must be provided") + .isExactlyInstanceOf(IllegalStateException.class); } - + @Test - public void decorateQualityGateStatusNewIssue() { - configureTestDefaults(); + void shouldDeleteSummaryCommentIfNoOtherCommentsInDiscussion() throws IOException { + String azureProject = "azure-project"; + String azureRepository = "azure-repo"; + int pullRequestId = 321; + + AnalysisSummary analysisSummary = mock(); + when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); + + when(analysisDetails.getPullRequestId()).thenReturn(Integer.toString(pullRequestId)); + when(projectAlmSettingDto.getAlmSlug()).thenReturn(azureProject); + when(projectAlmSettingDto.getAlmRepo()).thenReturn(azureRepository); + + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); + + AzureDevopsClient azureDevopsClient = mock(); + when(azureDevopsClientFactory.createClient(any(), any())).thenReturn(azureDevopsClient); + + PullRequest pullRequest = mock(); + when(pullRequest.getId()).thenReturn(pullRequestId); + when(azureDevopsClient.retrievePullRequest(any(), any(), anyInt())).thenReturn(pullRequest); + Repository repository = mock(); + Project project = mock(); + when(pullRequest.getRepository()).thenReturn(repository); + when(repository.getProject()).thenReturn(project); + when(project.getName()).thenReturn(azureProject); + when(repository.getRemoteUrl()).thenReturn("https://remote.url/path/to/repo"); + when(repository.getName()).thenReturn(azureRepository); + + ConnectionData connectionData = mock(); + ConnectionData.Identity authenticatedUser = mock(); + when(authenticatedUser.getId()).thenReturn("sonarqube"); + when(connectionData.getAuthenticatedUser()).thenReturn(authenticatedUser); + when(azureDevopsClient.getConnectionData()).thenReturn(connectionData); + + AzureDevOpsPullRequestDecorator underTest = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); + + IdentityRef sonarqubeUser = mock(); + when(sonarqubeUser.getId()).thenReturn("sonarqube"); + + Comment comment1 = mock(); + when(comment1.getId()).thenReturn(999); + when(comment1.getAuthor()).thenReturn(sonarqubeUser); + when(comment1.getContent()).thenReturn("Summary comment" + System.lineSeparator() + "[View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(comment1.getCommentType()).thenReturn(CommentType.TEXT); + + CommentThread discussion = mock(); + when(discussion.getId()).thenReturn(99); + when(discussion.getComments()).thenReturn(List.of(comment1)); + + CommentThread newSummaryThread = mock(); + when(azureDevopsClient.createThread(any(), any(), anyInt(), any())).thenReturn(newSummaryThread); + + when(azureDevopsClient.retrieveThreads(any(), any(), anyInt())).thenReturn(List.of(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); - DecorationResult result = pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); - assertThat(result.getPullRequestUrl()).isEqualTo(Optional.of(String.format("%s/%s/_git/%s/pullRequest/%s", wireMockRule.baseUrl(), azureProject, azureRepository, pullRequestId))); + verify(azureDevopsClient).deletePullRequestThreadComment(azureProject, azureRepository, pullRequestId, 99, 999); + verify(azureDevopsClient).retrieveThreads(azureProject, azureRepository, pullRequestId); } @Test - public void decorateQualityGateStatusClosedIssue() { - configureTestDefaults(); + void shouldAddNoteToSummaryCommentThreadIfOtherCommentsInDiscussion() throws IOException { + String azureProject = "azure-project"; + String azureRepository = "azure-repo"; + int pullRequestId = 321; - when(defaultIssue.getStatus()).thenReturn(Issue.STATUS_CLOSED); - when(defaultIssue.getLine()).thenReturn(18); + AnalysisSummary analysisSummary = mock(); + when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); + + when(analysisDetails.getPullRequestId()).thenReturn(Integer.toString(pullRequestId)); + when(projectAlmSettingDto.getAlmSlug()).thenReturn(azureProject); + when(projectAlmSettingDto.getAlmRepo()).thenReturn(azureRepository); + + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); + + AzureDevopsClient azureDevopsClient = mock(); + when(azureDevopsClientFactory.createClient(any(), any())).thenReturn(azureDevopsClient); + + PullRequest pullRequest = mock(); + when(pullRequest.getId()).thenReturn(pullRequestId); + when(azureDevopsClient.retrievePullRequest(any(), any(), anyInt())).thenReturn(pullRequest); + Repository repository = mock(); + Project project = mock(); + when(pullRequest.getRepository()).thenReturn(repository); + when(repository.getProject()).thenReturn(project); + when(project.getName()).thenReturn(azureProject); + when(repository.getRemoteUrl()).thenReturn("https://remote.url/path/to/repo"); + when(repository.getName()).thenReturn(azureRepository); + + AzureDevOpsPullRequestDecorator underTest = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); - DecorationResult result = pullRequestDecorator.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); - assertThat(result.getPullRequestUrl()).isEqualTo(Optional.of(String.format("%s/%s/_git/%s/pullRequest/%s", wireMockRule.baseUrl(), azureProject, azureRepository, pullRequestId))); + IdentityRef sonarqubeUser = mock(); + when(sonarqubeUser.getId()).thenReturn("sonarqube"); + + ConnectionData connectionData = mock(); + ConnectionData.Identity authenticatedUser = mock(); + when(authenticatedUser.getId()).thenReturn("sonarqube"); + when(connectionData.getAuthenticatedUser()).thenReturn(authenticatedUser); + when(azureDevopsClient.getConnectionData()).thenReturn(connectionData); + + Comment comment1 = mock(); + when(comment1.getId()).thenReturn(101); + when(comment1.getAuthor()).thenReturn(sonarqubeUser); + when(comment1.getContent()).thenReturn("Summary comment" + System.lineSeparator() + "[View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(comment1.getCommentType()).thenReturn(CommentType.TEXT); + + IdentityRef otherUser = mock(); + when(otherUser.getId()).thenReturn("username"); + Comment comment2 = mock(); + when(comment2.getId()).thenReturn(102); + when(comment2.getAuthor()).thenReturn(otherUser); + when(comment2.getContent()).thenReturn("Another comment"); + when(comment2.getCommentType()).thenReturn(CommentType.TEXT); + + CommentThread discussion = mock(); + when(discussion.getId()).thenReturn(101); + when(discussion.getComments()).thenReturn(List.of(comment1, comment2)); + + CommentThread newSummaryThread = mock(); + when(azureDevopsClient.createThread(any(), any(), anyInt(), any())).thenReturn(newSummaryThread); + + when(azureDevopsClient.retrieveThreads(any(), any(), anyInt())).thenReturn(List.of(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + ArgumentCaptor commentRequestArgumentCaptor = ArgumentCaptor.captor(); + verify(azureDevopsClient).addCommentToThread(eq(azureProject), eq(azureRepository), eq(pullRequestId), eq(101), commentRequestArgumentCaptor.capture()); + assertThat(commentRequestArgumentCaptor.getValue()).usingRecursiveComparison().isEqualTo(new CreateCommentRequest("This summary note is outdated, but due to other comments being present in this discussion, the discussion is not being being removed. Please manually resolve this discussion once the other comments have been reviewed.")); + verify(azureDevopsClient, never()).deletePullRequestThreadComment(any(), any(), anyInt(), anyInt(), anyInt()); + verify(azureDevopsClient).retrieveThreads(azureProject, azureRepository, pullRequestId); } @Test - public void shouldRemoveUserInfoFromRepositoryUrlForLinking() { - ScmInfoRepository scmInfoRepository = mock(ScmInfoRepository.class); - AzureDevopsClientFactory azureDevopsClientFactory = mock(AzureDevopsClientFactory.class); - ReportGenerator reportGenerator = mock(ReportGenerator.class); - MarkdownFormatterFactory markdownFormatterFactory = mock(MarkdownFormatterFactory.class); + void shouldNotTryAndCleanupNonSummaryNote() throws IOException { + String azureProject = "azure-project"; + String azureRepository = "azure-repo"; + int pullRequestId = 321; - AzureDevOpsPullRequestDecorator underTest = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); + AnalysisSummary analysisSummary = mock(); + when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); - Repository repository = mock(Repository.class); - when(repository.getRemoteUrl()).thenReturn("https://user@domain.com/path/to/repo"); - PullRequest pullRequest = mock(PullRequest.class); + when(analysisDetails.getPullRequestId()).thenReturn(Integer.toString(pullRequestId)); + when(projectAlmSettingDto.getAlmSlug()).thenReturn(azureProject); + when(projectAlmSettingDto.getAlmRepo()).thenReturn(azureRepository); + + when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); + + AzureDevopsClient azureDevopsClient = mock(); + when(azureDevopsClientFactory.createClient(any(), any())).thenReturn(azureDevopsClient); + + when(azureDevopsClient.getConnectionData()).thenThrow(new IOException("Dummy")); + + PullRequest pullRequest = mock(); + when(pullRequest.getId()).thenReturn(pullRequestId); + when(azureDevopsClient.retrievePullRequest(any(), any(), anyInt())).thenReturn(pullRequest); + Repository repository = mock(); + Project project = mock(); when(pullRequest.getRepository()).thenReturn(repository); - when(pullRequest.getId()).thenReturn(999); + when(repository.getProject()).thenReturn(project); + when(project.getName()).thenReturn(azureProject); + when(repository.getRemoteUrl()).thenReturn("https://remote.url/path/to/repo"); + when(repository.getName()).thenReturn(azureRepository); - AnalysisDetails analysisDetails = mock(AnalysisDetails.class); + AzureDevOpsPullRequestDecorator underTest = new AzureDevOpsPullRequestDecorator(scmInfoRepository, azureDevopsClientFactory, reportGenerator, markdownFormatterFactory); - assertThat(underTest.createFrontEndUrl(pullRequest, analysisDetails)).contains("https://domain.com/path/to/repo/pullRequest/999"); + IdentityRef sonarqubeUser = mock(); + when(sonarqubeUser.getId()).thenReturn("sonarqube"); + + Comment comment1 = mock(); + when(comment1.getId()).thenReturn(101); + when(comment1.getAuthor()).thenReturn(sonarqubeUser); + when(comment1.getContent()).thenReturn("Not Summary comment" + System.lineSeparator() + "[Don't View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(comment1.getCommentType()).thenReturn(CommentType.TEXT); + + IdentityRef otherUser = mock(); + when(otherUser.getId()).thenReturn("username"); + Comment comment2 = mock(); + when(comment2.getId()).thenReturn(102); + when(comment2.getAuthor()).thenReturn(otherUser); + when(comment2.getContent()).thenReturn("Another comment"); + when(comment2.getCommentType()).thenReturn(CommentType.TEXT); + + CommentThread discussion = mock(); + when(discussion.getId()).thenReturn(101); + when(discussion.getComments()).thenReturn(List.of(comment1, comment2)); + + when(azureDevopsClient.retrieveThreads(any(), any(), anyInt())).thenReturn(List.of(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(azureDevopsClient, never()).addCommentToThread(any(), any(), anyInt(), anyInt(), any()); + verify(azureDevopsClient, never()).deletePullRequestThreadComment(any(), any(), anyInt(), anyInt(), anyInt()); + verify(azureDevopsClient).retrieveThreads(azureProject, azureRepository, pullRequestId); } } 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 index 273698f10..33f1d3a2a 100644 --- 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 @@ -18,63 +18,67 @@ */ package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.gitlab; -import com.github.mc1arke.sonarqube.plugin.almclient.gitlab.LinkHeaderReader; -import com.github.mc1arke.sonarqube.plugin.almclient.gitlab.DefaultGitlabClientFactory; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PostAnalysisIssueVisitor; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.MarkdownFormatterFactory; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; -import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; -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.config.internal.Encryption; -import org.sonar.api.config.internal.Settings; -import org.sonar.api.issue.Issue; -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.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.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.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.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 { +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; - @Rule - public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort()); +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.sonar.api.ce.posttask.QualityGate; +import org.sonar.api.config.internal.Encryption; +import org.sonar.api.config.internal.Settings; +import org.sonar.api.issue.Issue; +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 com.github.mc1arke.sonarqube.plugin.almclient.gitlab.DefaultGitlabClientFactory; +import com.github.mc1arke.sonarqube.plugin.almclient.gitlab.LinkHeaderReader; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.AnalysisDetails; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.PostAnalysisIssueVisitor; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.markup.MarkdownFormatterFactory; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; + +class GitlabMergeRequestDecoratorIntegrationTest { + + @RegisterExtension + static WireMockExtension wireMockExtension = WireMockExtension.extensionOptions() + .failOnUnmatchedRequests(true) + .build(); @Test - public void decorateQualityGateStatusOk() { + void decorateQualityGateStatusOk() { decorateQualityGateStatus(QualityGate.Status.OK); } @Test - public void decorateQualityGateStatusError() { + void decorateQualityGateStatusError() { decorateQualityGateStatus(QualityGate.Status.ERROR); } @@ -94,10 +98,10 @@ private void decorateQualityGateStatus(QualityGate.Status status) { ProjectAlmSettingDto projectAlmSettingDto = mock(ProjectAlmSettingDto.class); AlmSettingDto almSettingDto = mock(AlmSettingDto.class); when(almSettingDto.getDecryptedPersonalAccessToken(any())).thenReturn("token"); - when(almSettingDto.getUrl()).thenReturn(wireMockRule.baseUrl()+"/api/v4"); + when(almSettingDto.getUrl()).thenReturn(wireMockExtension.baseUrl() + "/api/v4"); AnalysisDetails analysisDetails = mock(AnalysisDetails.class); - when(almSettingDto.getUrl()).thenReturn(wireMockRule.baseUrl()+"/api/v4"); + when(almSettingDto.getUrl()).thenReturn(wireMockExtension.baseUrl() + "/api/v4"); when(projectAlmSettingDto.getAlmRepo()).thenReturn(repositorySlug); when(projectAlmSettingDto.getMonorepo()).thenReturn(true); when(analysisDetails.getQualityGateStatus()).thenReturn(status); @@ -142,11 +146,11 @@ private void decorateQualityGateStatus(QualityGate.Status status) { when(analysisIssueSummary.format(any())).thenReturn("issué"); when(reportGenerator.createAnalysisIssueSummary(any(), any())).thenReturn(analysisIssueSummary); - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/user")).withHeader("PRIVATE-TOKEN", equalTo("token")).willReturn(okJson("{\n" + + wireMockExtension.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" + + wireMockExtension.stubFor(get(urlPathEqualTo("/api/v4/projects/" + urlEncode(repositorySlug) + "/merge_requests/" + mergeRequestIid)).willReturn(okJson("{\n" + " \"id\": 15235,\n" + " \"iid\": " + mergeRequestIid + ",\n" + " \"target_project_id\": " + sourceProjectId + ",\n" + @@ -159,12 +163,12 @@ private void decorateQualityGateStatus(QualityGate.Status status) { " \"source_project_id\": " + sourceProjectId + "\n" + "}"))); - wireMockRule.stubFor(get(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/commits")).willReturn(okJson("[\n" + + wireMockExtension.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( + wireMockExtension.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)) + @@ -192,29 +196,35 @@ private void decorateQualityGateStatus(QualityGate.Status status) { "," + discussionPostResponseBody(discussionId + 7, discussionNote(noteId + 11, user, "Sonarqube issue for anther project\\n[View in SonarQube](https://sonarqube.dummy/project/issues?id=abcd-" + projectKey + "&pullRequest=1234&issues=oldid&open=oldid)", true, false)) + + "," + + discussionPostResponseBody(discussionId + 8, + discussionNote(noteId + 12, user, "Old summary note, should be deleted\\n[View in SonarQube](https://sonarqube.dummy/dashboard?id=" + projectKey + "&pullRequest=1234)", true, false)) + "]"))); - wireMockRule.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + "/notes")) + wireMockExtension.stubFor(delete(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + 8 + "/notes/" + noteId + 12)) + .willReturn(noContent())); + + wireMockExtension.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")) + wireMockExtension.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")) + wireMockExtension.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"))) + wireMockExtension.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")) + wireMockExtension.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions")) .withRequestBody(equalTo("body=summary+comm%C3%A9nt%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")) + wireMockExtension.stubFor(post(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions")) .withRequestBody(equalTo("body=issu%C3%A9&" + urlEncode("position[base_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + urlEncode("position[start_sha]") + "=d6a420d043dfe85e7c240fd136fc6e197998b10a&" + @@ -225,12 +235,12 @@ private void decorateQualityGateStatus(QualityGate.Status status) { 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)) + wireMockExtension.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)) + wireMockExtension.stubFor(put(urlPathEqualTo("/api/v4/projects/" + sourceProjectId + "/merge_requests/" + mergeRequestIid + "/discussions/" + discussionId + 1)) .withQueryParam("resolved", equalTo("true")) .willReturn(ok()) ); 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 index 009e7dab8..bccad0ad2 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021-2022 Michael Clarke + * Copyright (C) 2021-2024 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -36,8 +36,9 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisIssueSummary; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.AnalysisSummary; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.report.ReportGenerator; -import org.junit.Before; -import org.junit.Test; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.sonar.api.ce.posttask.QualityGate; import org.sonar.api.issue.Issue; @@ -54,6 +55,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -69,7 +71,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class GitlabMergeRequestDecoratorTest { +class GitlabMergeRequestDecoratorTest { private static final long MERGE_REQUEST_IID = 123; private static final long PROJECT_ID = 101; @@ -101,8 +103,8 @@ public class GitlabMergeRequestDecoratorTest { private final GitlabMergeRequestDecorator underTest = new GitlabMergeRequestDecorator(scmInfoRepository, gitlabClientFactory, reportGenerator, markdownFormatterFactory); - @Before - public void setUp() throws IOException { + @BeforeEach + void setUp() throws IOException { when(analysisSummary.format(any())).thenReturn("Summary Comment"); when(reportGenerator.createAnalysisSummary(any())).thenReturn(analysisSummary); AnalysisIssueSummary analysisIssueSummary = mock(AnalysisIssueSummary.class); @@ -132,12 +134,12 @@ public void setUp() throws IOException { } @Test - public void shouldReturnCorrectDecoratorType() { + void shouldReturnCorrectDecoratorType() { assertThat(underTest.alm()).containsOnly(ALM.GITLAB); } @Test - public void shouldThrowErrorWhenPullRequestKeyNotNumeric() { + void shouldThrowErrorWhenPullRequestKeyNotNumeric() { when(analysisDetails.getPullRequestId()).thenReturn("non-MR-IID"); assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) @@ -146,7 +148,7 @@ public void shouldThrowErrorWhenPullRequestKeyNotNumeric() { } @Test - public void shouldThrowErrorWhenGitlabMergeRequestRetrievalFails() throws IOException { + void shouldThrowErrorWhenGitlabMergeRequestRetrievalFails() throws IOException { when(gitlabClient.getMergeRequest(any(), anyLong())).thenThrow(new IOException("dummy")); assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) @@ -155,7 +157,7 @@ public void shouldThrowErrorWhenGitlabMergeRequestRetrievalFails() throws IOExce } @Test - public void shouldThrowErrorWhenGitlabUserRetrievalFails() throws IOException { + void shouldThrowErrorWhenGitlabUserRetrievalFails() throws IOException { when(gitlabClient.getCurrentUser()).thenThrow(new IOException("dummy")); assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) @@ -164,7 +166,7 @@ public void shouldThrowErrorWhenGitlabUserRetrievalFails() throws IOException { } @Test - public void shouldThrowErrorWhenGitlabMergeRequestCommitsRetrievalFails() throws IOException { + void shouldThrowErrorWhenGitlabMergeRequestCommitsRetrievalFails() throws IOException { when(gitlabClient.getMergeRequestCommits(anyLong(), anyLong())).thenThrow(new IOException("dummy")); assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) @@ -173,7 +175,7 @@ public void shouldThrowErrorWhenGitlabMergeRequestCommitsRetrievalFails() throws } @Test - public void shouldThrowErrorWhenGitlabMergeRequestDiscussionRetrievalFails() throws IOException { + void shouldThrowErrorWhenGitlabMergeRequestDiscussionRetrievalFails() throws IOException { when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenThrow(new IOException("dummy")); assertThatThrownBy(() -> underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) @@ -182,7 +184,7 @@ public void shouldThrowErrorWhenGitlabMergeRequestDiscussionRetrievalFails() thr } @Test - public void shouldCloseDiscussionWithSingleResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { + void shouldCloseDiscussionWithSingleResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { Note note = mock(Note.class); when(note.getAuthor()).thenReturn(sonarqubeUser); when(note.getBody()).thenReturn("Post with no issue ID"); @@ -203,7 +205,7 @@ public void shouldCloseDiscussionWithSingleResolvableNoteFromSonarqubeUserButNoI assertThat(mergeRequestNoteArgumentCaptor.getValue()).isNotInstanceOf(CommitNote.class); } @Test - public void shouldNotCloseDiscussionWithSingleNonResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { + void shouldNotCloseDiscussionWithSingleNonResolvableNoteFromSonarqubeUserButNoIssueIdInBody() throws IOException { Note note = mock(Note.class); when(note.getAuthor()).thenReturn(sonarqubeUser); when(note.getBody()).thenReturn("Post with no issue ID"); @@ -221,7 +223,7 @@ public void shouldNotCloseDiscussionWithSingleNonResolvableNoteFromSonarqubeUser } @Test - public void shouldNotCloseDiscussionWithMultipleResolvableNotesFromSonarqubeUserButNoId() throws IOException { + 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"); @@ -249,7 +251,7 @@ public void shouldNotCloseDiscussionWithMultipleResolvableNotesFromSonarqubeUser } @Test - public void shouldCloseDiscussionWithResolvableNoteFromSonarqubeUserAndOnlySystemNoteFromOtherUser() throws IOException { + void shouldCloseDiscussionWithResolvableNoteFromSonarqubeUserAndOnlySystemNoteFromOtherUser() throws IOException { User otherUser = mock(User.class); when(otherUser.getUsername()).thenReturn("other.user@gitlab.dummy"); @@ -279,7 +281,7 @@ public void shouldCloseDiscussionWithResolvableNoteFromSonarqubeUserAndOnlySyste } @Test - public void shouldNotAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithNoId() throws IOException { + void shouldNotAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithNoId() throws IOException { User otherUser = mock(User.class); when(otherUser.getUsername()).thenReturn("other.user@gitlab.dummy"); @@ -310,7 +312,7 @@ public void shouldNotAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSona } @Test - public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndACloseMessageWithNoId() throws IOException { + 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"); @@ -339,7 +341,7 @@ public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNote } @Test - public void shouldCommentAboutCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithIssuedId() throws IOException { + void shouldCommentAboutCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndAnotherUserWithIssuedId() throws IOException { User otherUser = mock(User.class); when(otherUser.getUsername()).thenReturn("other.user@gitlab.dummy"); @@ -371,7 +373,7 @@ public void shouldCommentAboutCloseOfDiscussionWithMultipleResolvableNotesFromSo } @Test - public void shouldThrowErrorIfUnableToCleanUpDiscussionOnGitlab() throws IOException { + void shouldThrowErrorIfUnableToCleanUpDiscussionOnGitlab() throws IOException { User otherUser = mock(User.class); when(otherUser.getUsername()).thenReturn("other.user@gitlab.dummy"); @@ -406,7 +408,7 @@ public void shouldThrowErrorIfUnableToCleanUpDiscussionOnGitlab() throws IOExcep } @Test - public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeUserAndACloseMessageWithIssueId() throws IOException { + 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)"); @@ -435,7 +437,7 @@ public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNote } @Test - public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeWithOtherProjectId() throws IOException { + void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNotesFromSonarqubeWithOtherProjectId() 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)"); @@ -464,7 +466,7 @@ public void shouldNotCommentOrAttemptCloseOfDiscussionWithMultipleResolvableNote } @Test - public void shouldThrowErrorIfSubmittingNewIssueToGitlabFails() throws IOException { + void shouldThrowErrorIfSubmittingNewIssueToGitlabFails() throws IOException { PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); when(lightIssue.key()).thenReturn("issueKey1"); when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); @@ -506,7 +508,7 @@ public void shouldThrowErrorIfSubmittingNewIssueToGitlabFails() throws IOExcepti } @Test - public void shouldStartNewDiscussionForNewIssueFromCommitInMergeRequest() throws IOException { + void shouldStartNewDiscussionForNewIssueFromCommitInMergeRequest() throws IOException { PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); when(lightIssue.key()).thenReturn("issueKey1"); when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); @@ -545,7 +547,7 @@ public void shouldStartNewDiscussionForNewIssueFromCommitInMergeRequest() throws } @Test - public void shouldNotStartNewDiscussionForIssueWithExistingCommentFromCommitInMergeRequest() throws IOException { + void shouldNotStartNewDiscussionForIssueWithExistingCommentFromCommitInMergeRequest() throws IOException { PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); when(lightIssue.key()).thenReturn("issueKey1"); when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); @@ -590,7 +592,7 @@ public void shouldNotStartNewDiscussionForIssueWithExistingCommentFromCommitInMe } @Test - public void shouldNotCreateCommentsForIssuesWithNoLineNumbers() throws IOException { + void shouldNotCreateCommentsForIssuesWithNoLineNumbers() throws IOException { PostAnalysisIssueVisitor.LightIssue lightIssue = mock(PostAnalysisIssueVisitor.LightIssue.class); when(lightIssue.key()).thenReturn("issueKey1"); when(lightIssue.getStatus()).thenReturn(Issue.STATUS_OPEN); @@ -618,7 +620,7 @@ public void shouldNotCreateCommentsForIssuesWithNoLineNumbers() throws IOExcepti } @Test - public void shouldSubmitSuccessfulPipelineStatusAndResolvedSummaryCommentOnSuccessAnalysis() throws IOException { + void shouldSubmitSuccessfulPipelineStatusAndResolvedSummaryCommentOnSuccessAnalysis() throws IOException { when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.OK); when(analysisDetails.getCommitSha()).thenReturn("commitsha"); @@ -647,7 +649,7 @@ public void shouldSubmitSuccessfulPipelineStatusAndResolvedSummaryCommentOnSucce } @Test - public void shouldSubmitFailedPipelineStatusAndUnresolvedSummaryCommentOnFailedAnalysis() throws IOException { + void shouldSubmitFailedPipelineStatusAndUnresolvedSummaryCommentOnFailedAnalysis() throws IOException { when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); when(analysisDetails.getCommitSha()).thenReturn("other sha"); when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); @@ -678,7 +680,7 @@ public void shouldSubmitFailedPipelineStatusAndUnresolvedSummaryCommentOnFailedA } @Test - public void shouldThrowErrorWhenSubmitPipelineStatusToGitlabFails() throws IOException { + void shouldThrowErrorWhenSubmitPipelineStatusToGitlabFails() throws IOException { when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); when(analysisDetails.getCommitSha()).thenReturn("other sha"); when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); @@ -712,7 +714,7 @@ public void shouldThrowErrorWhenSubmitPipelineStatusToGitlabFails() throws IOExc } @Test - public void shouldThrowErrorWhenSubmitAnalysisToGitlabFails() throws IOException { + void shouldThrowErrorWhenSubmitAnalysisToGitlabFails() throws IOException { when(analysisDetails.getQualityGateStatus()).thenReturn(QualityGate.Status.ERROR); when(analysisDetails.getCommitSha()).thenReturn("other sha"); when(analysisDetails.getScannerProperty("com.github.mc1arke.sonarqube.plugin.branch.pullrequest.gitlab.pipelineId")).thenReturn(Optional.of("11")); @@ -739,17 +741,95 @@ public void shouldThrowErrorWhenSubmitAnalysisToGitlabFails() throws IOException } @Test - public void shouldReturnWebUrlFromMergeRequestIfScannerPropertyNotSet() { + void shouldReturnWebUrlFromMergeRequestIfScannerPropertyNotSet() { assertThat(underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) .usingRecursiveComparison() .isEqualTo(DecorationResult.builder().withPullRequestUrl(MERGE_REQUEST_WEB_URL).build()); } @Test - public void shouldReturnWebUrlFromScannerPropertyIfSet() { + void shouldReturnWebUrlFromScannerPropertyIfSet() { when(analysisDetails.getScannerProperty("sonar.pullrequest.gitlab.projectUrl")).thenReturn(Optional.of(MERGE_REQUEST_WEB_URL + "/additional")); assertThat(underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto)) .usingRecursiveComparison() .isEqualTo(DecorationResult.builder().withPullRequestUrl(MERGE_REQUEST_WEB_URL + "/additional/merge_requests/" + MERGE_REQUEST_IID).build()); } + + @Test + void shouldDeleteSummaryCommentIfNoOtherCommentsInDiscussion() throws IOException { + Note note = mock(); + when(note.getId()).thenReturn(101L); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Summary comment" + System.lineSeparator() + "[View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(note.isSystem()).thenReturn(false); + + Discussion discussion = mock(); + 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).deleteMergeRequestDiscussionNote(PROJECT_ID, MERGE_REQUEST_IID, "discussionId", 101); + verify(gitlabClient).getMergeRequestDiscussions(PROJECT_ID, MERGE_REQUEST_IID); + } + + @Test + void shouldAddNoteToSummaryCommentThreadIfOtherCommentsInDiscussion() throws IOException { + Note note = mock(); + when(note.getId()).thenReturn(101L); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Summary comment" + System.lineSeparator() + "[View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(note.isSystem()).thenReturn(false); + + User otherUser = mock(); + when(otherUser.getUsername()).thenReturn("username"); + Note note2 = mock(); + when(note2.getId()).thenReturn(102L); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("Another comment"); + when(note2.isSystem()).thenReturn(false); + + Discussion discussion = mock(); + when(discussion.getId()).thenReturn("discussionId"); + when(discussion.getNotes()).thenReturn(List.of(note, note2)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient).addMergeRequestDiscussionNote(PROJECT_ID, MERGE_REQUEST_IID, "discussionId", "This summary note is outdated, but due to other comments being present in this discussion, the discussion is not being being removed. Please manually resolve this discussion once the other comments have been reviewed."); + verify(gitlabClient, never()).deleteMergeRequestDiscussionNote(anyLong(), anyLong(), any(), anyLong()); + verify(gitlabClient).getMergeRequestDiscussions(PROJECT_ID, MERGE_REQUEST_IID); + } + + @Test + void shouldNotTryAndCleanupNonSummaryNote() throws IOException { + Note note = mock(); + when(note.getId()).thenReturn(101L); + when(note.getAuthor()).thenReturn(sonarqubeUser); + when(note.getBody()).thenReturn("Not Summary comment" + System.lineSeparator() + "[Don't View in SonarQube](http://host.domain/dashboard?id=projectKey&pullRequest=123)"); + when(note.isSystem()).thenReturn(false); + + User otherUser = mock(); + when(otherUser.getUsername()).thenReturn("username"); + Note note2 = mock(); + when(note2.getId()).thenReturn(102L); + when(note2.getAuthor()).thenReturn(otherUser); + when(note2.getBody()).thenReturn("Another comment"); + when(note2.isSystem()).thenReturn(false); + + Discussion discussion = mock(); + when(discussion.getId()).thenReturn("discussionId"); + when(discussion.getNotes()).thenReturn(List.of(note, note2)); + + when(gitlabClient.getMergeRequestDiscussions(anyLong(), anyLong())).thenReturn(Collections.singletonList(discussion)); + + underTest.decorateQualityGateStatus(analysisDetails, almSettingDto, projectAlmSettingDto); + + verify(gitlabClient, never()).addMergeRequestDiscussionNote(anyLong(), anyLong(), any(), any()); + verify(gitlabClient, never()).deleteMergeRequestDiscussionNote(anyLong(), anyLong(), any(), anyLong()); + verify(gitlabClient).getMergeRequestDiscussions(PROJECT_ID, MERGE_REQUEST_IID); + } }