]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12720 add comments to response of WS api/hotspots/show
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Mon, 9 Dec 2019 11:04:26 +0000 (12:04 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:30 +0000 (20:46 +0100)
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/ShowAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangelog.java [deleted file]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/ShowActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/IssueChangeWSSupportTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java
sonar-ws/src/main/protobuf/ws-hotspots.proto

index 7b5c3f5c1e5cde0337c05daf1cdc19908c3b623a..21ab159e1859f10e6f324e07cae0730b3cf2c57d 100644 (file)
@@ -38,8 +38,9 @@ import org.sonar.db.issue.IssueDto;
 import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.exceptions.NotFoundException;
-import org.sonar.server.issue.IssueChangelog;
-import org.sonar.server.issue.IssueChangelog.ChangelogLoadingContext;
+import org.sonar.server.issue.IssueChangeWSSupport;
+import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext;
+import org.sonar.server.issue.IssueChangeWSSupport.Load;
 import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.issue.ws.UserResponseFormatter;
 import org.sonar.server.security.SecurityStandards;
@@ -50,6 +51,7 @@ import org.sonarqube.ws.Hotspots.ShowWsResponse;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Strings.nullToEmpty;
 import static java.lang.String.format;
+import static java.util.Collections.singleton;
 import static java.util.Optional.ofNullable;
 import static org.sonar.api.utils.DateUtils.formatDateTime;
 import static org.sonar.server.ws.WsUtils.writeProtobuf;
@@ -63,17 +65,17 @@ public class ShowAction implements HotspotsWsAction {
   private final HotspotWsResponseFormatter responseFormatter;
   private final TextRangeResponseFormatter textRangeFormatter;
   private final UserResponseFormatter userFormatter;
-  private final IssueChangelog issueChangelog;
+  private final IssueChangeWSSupport issueChangeSupport;
 
   public ShowAction(DbClient dbClient, HotspotWsSupport hotspotWsSupport,
     HotspotWsResponseFormatter responseFormatter, TextRangeResponseFormatter textRangeFormatter,
-    UserResponseFormatter userFormatter, IssueChangelog issueChangelog) {
+    UserResponseFormatter userFormatter, IssueChangeWSSupport issueChangeSupport) {
     this.dbClient = dbClient;
     this.hotspotWsSupport = hotspotWsSupport;
     this.responseFormatter = responseFormatter;
     this.textRangeFormatter = textRangeFormatter;
     this.userFormatter = userFormatter;
-    this.issueChangelog = issueChangelog;
+    this.issueChangeSupport = issueChangeSupport;
   }
 
   @Override
@@ -109,7 +111,7 @@ public class ShowAction implements HotspotsWsAction {
       formatComponents(components, responseBuilder);
       formatRule(responseBuilder, rule);
       formatTextRange(hotspot, responseBuilder);
-      formatChangelog(dbSession, hotspot, components, responseBuilder);
+      formatChangeLogAndComments(dbSession, hotspot, components, responseBuilder);
 
       writeProtobuf(responseBuilder.build(), request, response);
     }
@@ -175,13 +177,15 @@ public class ShowAction implements HotspotsWsAction {
     textRangeFormatter.formatTextRange(hotspot, responseBuilder::setTextRange);
   }
 
-  private void formatChangelog(DbSession dbSession, IssueDto hotspot, Components components, ShowWsResponse.Builder responseBuilder) {
+  private void formatChangeLogAndComments(DbSession dbSession, IssueDto hotspot, Components components, ShowWsResponse.Builder responseBuilder) {
     Set<ComponentDto> preloadedComponents = ImmutableSet.of(components.project, components.component);
-    ChangelogLoadingContext changelogLoadingContext = issueChangelog
-      .newChangelogLoadingContext(dbSession, hotspot, ImmutableSet.of(), preloadedComponents);
+    FormattingContext formattingContext = issueChangeSupport
+      .newFormattingContext(dbSession, singleton(hotspot), Load.ALL, ImmutableSet.of(), preloadedComponents);
 
-    issueChangelog.formatChangelog(dbSession, changelogLoadingContext)
+    issueChangeSupport.formatChangelog(hotspot, formattingContext)
       .forEach(responseBuilder::addChangelog);
+    issueChangeSupport.formatComments(hotspot, Common.Comment.newBuilder(), formattingContext)
+      .forEach(responseBuilder::addComment);
   }
 
   private RuleDefinitionDto loadRule(DbSession dbSession, IssueDto hotspot) {
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java
new file mode 100644 (file)
index 0000000..986dc4d
--- /dev/null
@@ -0,0 +1,354 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * 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 org.sonar.server.issue;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import org.sonar.api.utils.DateUtils;
+import org.sonar.core.issue.FieldDiffs;
+import org.sonar.core.util.stream.MoreCollectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.issue.IssueChangeDto;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.markdown.Markdown;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.Common;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static java.util.Collections.emptyMap;
+import static java.util.Optional.empty;
+import static java.util.Optional.ofNullable;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.core.util.stream.MoreCollectors.toList;
+import static org.sonar.core.util.stream.MoreCollectors.toSet;
+import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
+import static org.sonar.db.issue.IssueChangeDto.TYPE_COMMENT;
+import static org.sonar.db.issue.IssueChangeDto.TYPE_FIELD_CHANGE;
+import static org.sonar.server.issue.IssueFieldsSetter.FILE;
+import static org.sonar.server.issue.IssueFieldsSetter.TECHNICAL_DEBT;
+
+public class IssueChangeWSSupport {
+  private static final String EFFORT_CHANGELOG_KEY = "effort";
+  private static final Ordering<IssueChangeDto> ISSUE_CHANGE_CREATED_AT_COMPARATOR = Ordering.natural().onResultOf(IssueChangeDto::getCreatedAt);
+
+  private final DbClient dbClient;
+  private final AvatarResolver avatarFactory;
+  private final UserSession userSession;
+
+  public IssueChangeWSSupport(DbClient dbClient, AvatarResolver avatarFactory, UserSession userSession) {
+    this.dbClient = dbClient;
+    this.avatarFactory = avatarFactory;
+    this.userSession = userSession;
+  }
+
+  public enum Load {
+    CHANGE_LOG, COMMENTS, ALL;
+  }
+
+  public interface FormattingContext {
+
+    List<FieldDiffs> getChanges(IssueDto dto);
+
+    List<IssueChangeDto> getComments(IssueDto dto);
+
+    Optional<UserDto> getUserByUuid(@Nullable String uuid);
+
+    Optional<ComponentDto> getFileByUuid(@Nullable String uuid);
+
+    boolean isUpdatableComment(IssueChangeDto comment);
+  }
+
+  public FormattingContext newFormattingContext(DbSession dbSession, Set<IssueDto> dtos, Load load) {
+    return newFormattingContext(dbSession, dtos, load, ImmutableSet.of(), ImmutableSet.of());
+  }
+
+  public FormattingContext newFormattingContext(DbSession dbSession, Set<IssueDto> dtos, Load load,
+    Set<UserDto> preloadedUsers, Set<ComponentDto> preloadedComponents) {
+    Set<String> issueKeys = dtos.stream().map(IssueDto::getKey).collect(toSet());
+
+    List<IssueChangeDto> changes = ImmutableList.of();
+    List<IssueChangeDto> comments = ImmutableList.of();
+    switch (load) {
+      case CHANGE_LOG:
+        changes = dbClient.issueChangeDao().selectByTypeAndIssueKeys(dbSession, issueKeys, TYPE_FIELD_CHANGE);
+        break;
+      case COMMENTS:
+        comments = dbClient.issueChangeDao().selectByTypeAndIssueKeys(dbSession, issueKeys, TYPE_COMMENT);
+        break;
+      case ALL:
+        List<IssueChangeDto> all = dbClient.issueChangeDao().selectByIssueKeys(dbSession, issueKeys);
+        changes = all.stream()
+          .filter(t -> TYPE_FIELD_CHANGE.equals(t.getChangeType()))
+          .collect(toList());
+        comments = all.stream()
+          .filter(t -> TYPE_COMMENT.equals(t.getChangeType()))
+          .collect(toList());
+        break;
+      default:
+        throw new IllegalStateException("Unsupported Load value:" + load);
+    }
+
+    Map<String, List<FieldDiffs>> changesByRuleKey = indexAndSort(changes, IssueChangeDto::toFieldDiffs);
+    Map<String, List<IssueChangeDto>> commentsByIssueKey = indexAndSort(comments, t -> t);
+    Map<String, UserDto> usersByUuid = loadUsers(dbSession, changesByRuleKey, commentsByIssueKey, preloadedUsers);
+    Map<String, ComponentDto> filesByUuid = loadFiles(dbSession, changesByRuleKey, preloadedComponents);
+    Map<String, Boolean> updatableCommentByKey = loadUpdatableFlag(commentsByIssueKey);
+    return new FormattingContextImpl(changesByRuleKey, commentsByIssueKey, usersByUuid, filesByUuid, updatableCommentByKey);
+  }
+
+  private static <T> Map<String, List<T>> indexAndSort(List<IssueChangeDto> changes, Function<IssueChangeDto, T> transform) {
+    Multimap<String, IssueChangeDto> unordered = changes.stream()
+      .collect(MoreCollectors.index(IssueChangeDto::getIssueKey, t -> t));
+    return unordered.asMap().entrySet().stream()
+      .collect(uniqueIndex(
+        Map.Entry::getKey,
+        t -> t.getValue().stream()
+          .sorted(ISSUE_CHANGE_CREATED_AT_COMPARATOR)
+          .map(transform)
+          .collect(toList(t.getValue().size()))));
+  }
+
+  private Map<String, UserDto> loadUsers(DbSession dbSession, Map<String, List<FieldDiffs>> changesByRuleKey,
+    Map<String, List<IssueChangeDto>> commentsByIssueKey, Set<UserDto> preloadedUsers) {
+    Set<String> userUuids = Stream.concat(
+      changesByRuleKey.values().stream()
+        .flatMap(Collection::stream)
+        .map(FieldDiffs::userUuid),
+      commentsByIssueKey.values().stream()
+        .flatMap(Collection::stream)
+        .map(IssueChangeDto::getUserUuid))
+      .filter(Objects::nonNull)
+      .collect(toSet());
+    if (userUuids.isEmpty()) {
+      return emptyMap();
+    }
+
+    Set<String> preloadedUserUuids = preloadedUsers.stream().map(UserDto::getUuid).collect(Collectors.toSet());
+    Set<String> missingUsersUuids = Sets.difference(userUuids, preloadedUserUuids).immutableCopy();
+    if (missingUsersUuids.isEmpty()) {
+      return preloadedUsers.stream()
+        .filter(t -> userUuids.contains(t.getUuid()))
+        .collect(uniqueIndex(UserDto::getUuid, userUuids.size()));
+    }
+
+    return Stream.concat(
+      preloadedUsers.stream(),
+      dbClient.userDao().selectByUuids(dbSession, missingUsersUuids).stream())
+      .filter(t -> userUuids.contains(t.getUuid()))
+      .collect(uniqueIndex(UserDto::getUuid, userUuids.size()));
+  }
+
+  private Map<String, ComponentDto> loadFiles(DbSession dbSession, Map<String, List<FieldDiffs>> changesByRuleKey, Set<ComponentDto> preloadedComponents) {
+    Set<String> fileUuids = changesByRuleKey.values().stream()
+      .flatMap(Collection::stream)
+      .flatMap(diffs -> {
+        FieldDiffs.Diff diff = diffs.get(FILE);
+        if (diff == null) {
+          return Stream.empty();
+        }
+        return Stream.of(toString(diff.newValue()), toString(diff.oldValue()));
+      })
+      .map(Strings::emptyToNull)
+      .filter(Objects::nonNull)
+      .collect(toSet());
+    if (fileUuids.isEmpty()) {
+      return emptyMap();
+    }
+
+    Set<String> preloadedFileUuids = preloadedComponents.stream().map(ComponentDto::uuid).collect(Collectors.toSet());
+    Set<String> missingFileUuids = Sets.difference(fileUuids, preloadedFileUuids).immutableCopy();
+    if (missingFileUuids.isEmpty()) {
+      return preloadedComponents.stream()
+        .filter(t -> fileUuids.contains(t.uuid()))
+        .collect(uniqueIndex(ComponentDto::uuid, fileUuids.size()));
+    }
+
+    return Stream.concat(
+      preloadedComponents.stream(),
+      dbClient.componentDao().selectByUuids(dbSession, missingFileUuids).stream())
+      .filter(t -> fileUuids.contains(t.uuid()))
+      .collect(uniqueIndex(ComponentDto::uuid, fileUuids.size()));
+  }
+
+  private Map<String, Boolean> loadUpdatableFlag(Map<String, List<IssueChangeDto>> commentsByIssueKey) {
+    if (!userSession.isLoggedIn()) {
+      return emptyMap();
+    }
+    String userUuid = userSession.getUuid();
+    if (userUuid == null) {
+      return emptyMap();
+    }
+
+    return commentsByIssueKey.values().stream()
+      .flatMap(Collection::stream)
+      .collect(uniqueIndex(IssueChangeDto::getKey, t -> userUuid.equals(t.getUserUuid())));
+  }
+
+  public Stream<Common.Changelog> formatChangelog(IssueDto dto, FormattingContext formattingContext) {
+    return formattingContext.getChanges(dto).stream()
+      .map(toWsChangelog(formattingContext));
+  }
+
+  private Function<FieldDiffs, Common.Changelog> toWsChangelog(FormattingContext formattingContext) {
+    return change -> {
+      String userUUuid = change.userUuid();
+      Common.Changelog.Builder changelogBuilder = Common.Changelog.newBuilder();
+      changelogBuilder.setCreationDate(formatDateTime(change.creationDate()));
+      formattingContext.getUserByUuid(userUUuid)
+        .ifPresent(user -> {
+          changelogBuilder.setUser(user.getLogin());
+          changelogBuilder.setIsUserActive(user.isActive());
+          ofNullable(user.getName()).ifPresent(changelogBuilder::setUserName);
+          ofNullable(emptyToNull(user.getEmail())).ifPresent(email -> changelogBuilder.setAvatar(avatarFactory.create(user)));
+        });
+      change.diffs().entrySet().stream()
+        .map(toWsDiff(formattingContext))
+        .forEach(changelogBuilder::addDiffs);
+      return changelogBuilder.build();
+    };
+  }
+
+  private static Function<Map.Entry<String, FieldDiffs.Diff>, Common.Changelog.Diff> toWsDiff(FormattingContext formattingContext) {
+    return diff -> {
+      FieldDiffs.Diff value = diff.getValue();
+      Common.Changelog.Diff.Builder diffBuilder = Common.Changelog.Diff.newBuilder();
+      String key = diff.getKey();
+      String oldValue = emptyToNull(toString(value.oldValue()));
+      String newValue = emptyToNull(toString(value.newValue()));
+      if (key.equals(FILE)) {
+        diffBuilder.setKey(key);
+        formattingContext.getFileByUuid(newValue).map(ComponentDto::longName).ifPresent(diffBuilder::setNewValue);
+        formattingContext.getFileByUuid(oldValue).map(ComponentDto::longName).ifPresent(diffBuilder::setOldValue);
+      } else {
+        diffBuilder.setKey(key.equals(TECHNICAL_DEBT) ? EFFORT_CHANGELOG_KEY : key);
+        ofNullable(newValue).ifPresent(diffBuilder::setNewValue);
+        ofNullable(oldValue).ifPresent(diffBuilder::setOldValue);
+      }
+      return diffBuilder.build();
+    };
+  }
+
+  public Stream<Common.Comment> formatComments(IssueDto dto, Common.Comment.Builder commentBuilder, FormattingContext formattingContext) {
+    return formattingContext.getComments(dto).stream()
+      .map(comment -> {
+        commentBuilder
+          .clear()
+          .setKey(comment.getKey())
+          .setUpdatable(formattingContext.isUpdatableComment(comment))
+          .setCreatedAt(DateUtils.formatDateTime(new Date(comment.getIssueChangeCreationDate())));
+        String markdown = comment.getChangeData();
+        formattingContext.getUserByUuid(comment.getUserUuid()).ifPresent(user -> commentBuilder.setLogin(user.getLogin()));
+        if (markdown != null) {
+          commentBuilder
+            .setHtmlText(Markdown.convertToHtml(markdown))
+            .setMarkdown(markdown);
+        }
+        return commentBuilder.build();
+      });
+  }
+
+  private static String toString(@Nullable Serializable serializable) {
+    if (serializable != null) {
+      return serializable.toString();
+    }
+    return null;
+  }
+
+  @Immutable
+  public static final class FormattingContextImpl implements FormattingContext {
+    private final Map<String, List<FieldDiffs>> changesByIssueKey;
+    private final Map<String, List<IssueChangeDto>> commentsByIssueKey;
+    private final Map<String, UserDto> usersByUuid;
+    private final Map<String, ComponentDto> filesByUuid;
+    private final Map<String, Boolean> updatableCommentByKey;
+
+    private FormattingContextImpl(Map<String, List<FieldDiffs>> changesByIssueKey,
+      Map<String, List<IssueChangeDto>> commentsByIssueKey,
+      Map<String, UserDto> usersByUuid, Map<String, ComponentDto> filesByUuid,
+      Map<String, Boolean> updatableCommentByKey) {
+      this.changesByIssueKey = changesByIssueKey;
+      this.commentsByIssueKey = commentsByIssueKey;
+      this.usersByUuid = usersByUuid;
+      this.filesByUuid = filesByUuid;
+      this.updatableCommentByKey = updatableCommentByKey;
+    }
+
+    @Override
+    public List<FieldDiffs> getChanges(IssueDto dto) {
+      List<FieldDiffs> fieldDiffs = changesByIssueKey.get(dto.getKey());
+      if (fieldDiffs == null) {
+        return ImmutableList.of();
+      }
+      return ImmutableList.copyOf(fieldDiffs);
+    }
+
+    @Override
+    public List<IssueChangeDto> getComments(IssueDto dto) {
+      List<IssueChangeDto> comments = commentsByIssueKey.get(dto.getKey());
+      if (comments == null) {
+        return ImmutableList.of();
+      }
+      return ImmutableList.copyOf(comments);
+    }
+
+    @Override
+    public Optional<UserDto> getUserByUuid(@Nullable String uuid) {
+      if (uuid == null) {
+        return empty();
+      }
+      return Optional.ofNullable(usersByUuid.get(uuid));
+    }
+
+    @Override
+    public Optional<ComponentDto> getFileByUuid(@Nullable String uuid) {
+      if (uuid == null) {
+        return empty();
+      }
+      return Optional.ofNullable(filesByUuid.get(uuid));
+    }
+
+    @Override
+    public boolean isUpdatableComment(IssueChangeDto comment) {
+      Boolean flag = updatableCommentByKey.get(comment.getKey());
+      return flag != null && flag;
+    }
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangelog.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangelog.java
deleted file mode 100644 (file)
index 56f0e70..0000000
+++ /dev/null
@@ -1,237 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * 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 org.sonar.server.issue;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Stream;
-import javax.annotation.CheckForNull;
-import javax.annotation.Nullable;
-import javax.annotation.concurrent.Immutable;
-import org.sonar.core.issue.FieldDiffs;
-import org.sonar.db.DbClient;
-import org.sonar.db.DbSession;
-import org.sonar.db.component.ComponentDto;
-import org.sonar.db.issue.IssueDto;
-import org.sonar.db.user.UserDto;
-import org.sonarqube.ws.Common;
-
-import static com.google.common.base.Strings.emptyToNull;
-import static java.util.Collections.emptyMap;
-import static java.util.Optional.ofNullable;
-import static org.sonar.api.utils.DateUtils.formatDateTime;
-import static org.sonar.core.util.stream.MoreCollectors.toSet;
-import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
-import static org.sonar.server.issue.IssueFieldsSetter.FILE;
-import static org.sonar.server.issue.IssueFieldsSetter.TECHNICAL_DEBT;
-
-public class IssueChangelog {
-  private static final String EFFORT_CHANGELOG_KEY = "effort";
-
-  private final DbClient dbClient;
-  private final AvatarResolver avatarFactory;
-
-  public IssueChangelog(DbClient dbClient, AvatarResolver avatarFactory) {
-    this.dbClient = dbClient;
-    this.avatarFactory = avatarFactory;
-  }
-
-  public ChangelogLoadingContext newChangelogLoadingContext(DbSession dbSession, IssueDto dto) {
-    return newChangelogLoadingContext(dbSession, dto, ImmutableSet.of(), ImmutableSet.of());
-  }
-
-  public ChangelogLoadingContext newChangelogLoadingContext(DbSession dbSession, IssueDto dto, Set<UserDto> preloadedUsers, Set<ComponentDto> preloadedComponents) {
-    List<FieldDiffs> changes = dbClient.issueChangeDao().selectChangelogByIssue(dbSession, dto.getKey());
-    return new ChangelogLoadingContextImpl(changes, preloadedUsers, preloadedComponents);
-  }
-
-  public Stream<Common.Changelog> formatChangelog(DbSession dbSession, ChangelogLoadingContext loadingContext) {
-    Map<String, UserDto> usersByUuid = loadUsers(dbSession, loadingContext);
-    Map<String, ComponentDto> filesByUuid = loadFiles(dbSession, loadingContext);
-    FormatableChangeLog changeLogResults = new FormatableChangeLog(loadingContext.getChanges(), usersByUuid, filesByUuid);
-
-    return changeLogResults.changes.stream()
-      .map(toWsChangelog(changeLogResults));
-  }
-
-  private Map<String, UserDto> loadUsers(DbSession dbSession, ChangelogLoadingContext loadingContext) {
-    List<FieldDiffs> changes = loadingContext.getChanges();
-    if (changes.isEmpty()) {
-      return emptyMap();
-    }
-
-    Set<UserDto> usersByUuid = loadingContext.getPreloadedUsers();
-
-    Set<String> userUuids = changes.stream()
-      .filter(change -> change.userUuid() != null)
-      .map(FieldDiffs::userUuid)
-      .collect(toSet());
-    if (userUuids.isEmpty()) {
-      return emptyMap();
-    }
-
-    Set<String> missingUsersUuids = Sets.difference(userUuids, usersByUuid).immutableCopy();
-    if (missingUsersUuids.isEmpty()) {
-      return usersByUuid.stream()
-        .filter(t -> userUuids.contains(t.getUuid()))
-        .collect(uniqueIndex(UserDto::getUuid, userUuids.size()));
-    }
-
-    return Stream.concat(
-      usersByUuid.stream(),
-      dbClient.userDao().selectByUuids(dbSession, missingUsersUuids).stream())
-      .filter(t -> userUuids.contains(t.getUuid()))
-      .collect(uniqueIndex(UserDto::getUuid, userUuids.size()));
-  }
-
-  private Map<String, ComponentDto> loadFiles(DbSession dbSession, ChangelogLoadingContext loadingContext) {
-    List<FieldDiffs> changes = loadingContext.getChanges();
-    if (changes.isEmpty()) {
-      return emptyMap();
-    }
-
-    Set<String> fileUuids = changes.stream()
-      .filter(diffs -> diffs.diffs().containsKey(FILE))
-      .flatMap(diffs -> Stream.of(diffs.get(FILE).newValue().toString(), diffs.get(FILE).oldValue().toString()))
-      .collect(toSet());
-    if (fileUuids.isEmpty()) {
-      return emptyMap();
-    }
-
-    Set<ComponentDto> preloadedComponents = loadingContext.getPreloadedComponents();
-    Set<String> preloadedComponentUuids = preloadedComponents.stream()
-      .map(ComponentDto::uuid)
-      .collect(toSet(preloadedComponents.size()));
-    Set<String> missingFileUuids = Sets.difference(fileUuids, preloadedComponentUuids).immutableCopy();
-    if (missingFileUuids.isEmpty()) {
-      return preloadedComponents.stream()
-        .filter(t -> fileUuids.contains(t.uuid()))
-        .collect(uniqueIndex(ComponentDto::uuid, fileUuids.size()));
-    }
-
-    return Stream.concat(
-      preloadedComponents.stream(),
-      dbClient.componentDao().selectByUuids(dbSession, missingFileUuids).stream())
-      .filter(t -> fileUuids.contains(t.uuid()))
-      .collect(uniqueIndex(ComponentDto::uuid, fileUuids.size()));
-  }
-
-  public interface ChangelogLoadingContext {
-    List<FieldDiffs> getChanges();
-
-    Set<UserDto> getPreloadedUsers();
-
-    Set<ComponentDto> getPreloadedComponents();
-  }
-
-  @Immutable
-  public static final class ChangelogLoadingContextImpl implements ChangelogLoadingContext {
-    private final List<FieldDiffs> changes;
-    private final Set<UserDto> preloadedUsers;
-    private final Set<ComponentDto> preloadedComponents;
-
-    private ChangelogLoadingContextImpl(List<FieldDiffs> changes, Set<UserDto> preloadedUsers, Set<ComponentDto> preloadedComponents) {
-      this.changes = ImmutableList.copyOf(changes);
-      this.preloadedUsers = ImmutableSet.copyOf(preloadedUsers);
-      this.preloadedComponents = ImmutableSet.copyOf(preloadedComponents);
-    }
-
-    @Override
-    public List<FieldDiffs> getChanges() {
-      return changes;
-    }
-
-    @Override
-    public Set<UserDto> getPreloadedUsers() {
-      return preloadedUsers;
-    }
-
-    @Override
-    public Set<ComponentDto> getPreloadedComponents() {
-      return preloadedComponents;
-    }
-  }
-
-  private Function<FieldDiffs, Common.Changelog> toWsChangelog(FormatableChangeLog results) {
-    return change -> {
-      String userUUuid = change.userUuid();
-      Common.Changelog.Builder changelogBuilder = Common.Changelog.newBuilder();
-      changelogBuilder.setCreationDate(formatDateTime(change.creationDate()));
-      UserDto user = userUUuid == null ? null : results.users.get(userUUuid);
-      if (user != null) {
-        changelogBuilder.setUser(user.getLogin());
-        changelogBuilder.setIsUserActive(user.isActive());
-        ofNullable(user.getName()).ifPresent(changelogBuilder::setUserName);
-        ofNullable(emptyToNull(user.getEmail())).ifPresent(email -> changelogBuilder.setAvatar(avatarFactory.create(user)));
-      }
-      change.diffs().entrySet().stream()
-        .map(toWsDiff(results))
-        .forEach(changelogBuilder::addDiffs);
-      return changelogBuilder.build();
-    };
-  }
-
-  private static Function<Map.Entry<String, FieldDiffs.Diff>, Common.Changelog.Diff> toWsDiff(FormatableChangeLog results) {
-    return diff -> {
-      FieldDiffs.Diff value = diff.getValue();
-      Common.Changelog.Diff.Builder diffBuilder = Common.Changelog.Diff.newBuilder();
-      String key = diff.getKey();
-      String oldValue = value.oldValue() != null ? value.oldValue().toString() : null;
-      String newValue = value.newValue() != null ? value.newValue().toString() : null;
-      if (key.equals(FILE)) {
-        diffBuilder.setKey(key);
-        ofNullable(results.getFileLongName(emptyToNull(newValue))).ifPresent(diffBuilder::setNewValue);
-        ofNullable(results.getFileLongName(emptyToNull(oldValue))).ifPresent(diffBuilder::setOldValue);
-      } else {
-        diffBuilder.setKey(key.equals(TECHNICAL_DEBT) ? EFFORT_CHANGELOG_KEY : key);
-        ofNullable(emptyToNull(newValue)).ifPresent(diffBuilder::setNewValue);
-        ofNullable(emptyToNull(oldValue)).ifPresent(diffBuilder::setOldValue);
-      }
-      return diffBuilder.build();
-    };
-  }
-
-  private static final class FormatableChangeLog {
-    private final List<FieldDiffs> changes;
-    private final Map<String, UserDto> users;
-    private final Map<String, ComponentDto> files;
-
-    private FormatableChangeLog(List<FieldDiffs> changes, Map<String, UserDto> users, Map<String, ComponentDto> files) {
-      this.changes = changes;
-      this.users = users;
-      this.files = files;
-    }
-
-    @CheckForNull
-    String getFileLongName(@Nullable String fileUuid) {
-      if (fileUuid == null) {
-        return null;
-      }
-      ComponentDto file = files.get(fileUuid);
-      return file == null ? null : file.longName();
-    }
-
-  }
-}
index ff6246b5149dedcf58c768b33cca0eb129af6baa..eccc9a86d1be5c8f02d97c0783e5be4574d9ccda 100644 (file)
@@ -30,12 +30,14 @@ import org.sonar.db.DbSession;
 import org.sonar.db.component.ComponentDto;
 import org.sonar.db.issue.IssueDto;
 import org.sonar.db.organization.OrganizationDto;
-import org.sonar.server.issue.IssueChangelog;
+import org.sonar.server.issue.IssueChangeWSSupport;
+import org.sonar.server.issue.IssueChangeWSSupport.Load;
 import org.sonar.server.issue.IssueFinder;
 import org.sonar.server.user.UserSession;
 import org.sonarqube.ws.Issues.ChangelogWsResponse;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Collections.singleton;
 import static org.sonar.core.util.Uuids.UUID_EXAMPLE_01;
 import static org.sonar.server.ws.WsUtils.writeProtobuf;
 import static org.sonarqube.ws.client.issue.IssuesWsParameters.ACTION_CHANGELOG;
@@ -46,13 +48,13 @@ public class ChangelogAction implements IssuesWsAction {
   private final DbClient dbClient;
   private final IssueFinder issueFinder;
   private final UserSession userSession;
-  private final IssueChangelog issueChangelog;
+  private final IssueChangeWSSupport issueChangeSupport;
 
-  public ChangelogAction(DbClient dbClient, IssueFinder issueFinder, UserSession userSession, IssueChangelog issueChangelog) {
+  public ChangelogAction(DbClient dbClient, IssueFinder issueFinder, UserSession userSession, IssueChangeWSSupport issueChangeSupport) {
     this.dbClient = dbClient;
     this.issueFinder = issueFinder;
     this.userSession = userSession;
-    this.issueChangelog = issueChangelog;
+    this.issueChangeSupport = issueChangeSupport;
   }
 
   @Override
@@ -86,10 +88,10 @@ public class ChangelogAction implements IssuesWsAction {
       return ChangelogWsResponse.newBuilder().build();
     }
 
-    IssueChangelog.ChangelogLoadingContext loadingContext = issueChangelog.newChangelogLoadingContext(dbSession, issue);
+    IssueChangeWSSupport.FormattingContext formattingContext = issueChangeSupport.newFormattingContext(dbSession, singleton(issue), Load.CHANGE_LOG);
 
     ChangelogWsResponse.Builder builder = ChangelogWsResponse.newBuilder();
-    issueChangelog.formatChangelog(dbSession, loadingContext)
+    issueChangeSupport.formatChangelog(issue, formattingContext)
       .forEach(builder::addChangelog);
     return builder.build();
   }
index dd92918d965dc518d92ebff402334d0dc581a3cb..904eacb305e8e1366e5eb8fd68f1189a44c44c99 100644 (file)
@@ -21,7 +21,7 @@ package org.sonar.server.issue.ws;
 
 import org.sonar.core.platform.Module;
 import org.sonar.server.issue.AvatarResolverImpl;
-import org.sonar.server.issue.IssueChangelog;
+import org.sonar.server.issue.IssueChangeWSSupport;
 import org.sonar.server.issue.IssueFieldsSetter;
 import org.sonar.server.issue.IssueFinder;
 import org.sonar.server.issue.TextRangeResponseFormatter;
@@ -46,7 +46,7 @@ public class IssueWsModule extends Module {
       IssueQueryFactory.class,
       IssuesWs.class,
       AvatarResolverImpl.class,
-      IssueChangelog.class,
+      IssueChangeWSSupport.class,
       SearchResponseLoader.class,
       TextRangeResponseFormatter.class,
       UserResponseFormatter.class,
index 2317cee6b050665835513db42d66844fd9db1f6f..1a0b8b61336d81fc8da14804cb3ccaa6194cbccf 100644 (file)
@@ -59,8 +59,9 @@ import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.issue.AvatarResolver;
 import org.sonar.server.issue.AvatarResolverImpl;
-import org.sonar.server.issue.IssueChangelog;
-import org.sonar.server.issue.IssueChangelog.ChangelogLoadingContext;
+import org.sonar.server.issue.IssueChangeWSSupport;
+import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext;
+import org.sonar.server.issue.IssueChangeWSSupport.Load;
 import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.issue.ws.UserResponseFormatter;
 import org.sonar.server.organization.TestDefaultOrganizationProvider;
@@ -101,12 +102,12 @@ public class ShowActionTest {
 
   private AvatarResolver avatarResolver = new AvatarResolverImpl();
   private HotspotWsResponseFormatter responseFormatter = new HotspotWsResponseFormatter(defaultOrganizationProvider);
-  private IssueChangelog issueChangelog = Mockito.mock(IssueChangelog.class);
+  private IssueChangeWSSupport issueChangeSupport = Mockito.mock(IssueChangeWSSupport.class);
   private HotspotWsSupport hotspotWsSupport = new HotspotWsSupport(dbClient, userSessionRule, System2.INSTANCE);
   private UserResponseFormatter userFormatter = new UserResponseFormatter(new AvatarResolverImpl());
   private TextRangeResponseFormatter textRangeFormatter = new TextRangeResponseFormatter();
 
-  private ShowAction underTest = new ShowAction(dbClient, hotspotWsSupport, responseFormatter, textRangeFormatter, userFormatter, issueChangelog);
+  private ShowAction underTest = new ShowAction(dbClient, hotspotWsSupport, responseFormatter, textRangeFormatter, userFormatter, issueChangeSupport);
   private WsActionTester actionTester = new WsActionTester(underTest);
 
   @Test
@@ -556,7 +557,7 @@ public class ShowActionTest {
   }
 
   @Test
-  public void returns_hotspot_changelog() {
+  public void returns_hotspot_changelog_and_comments() {
     ComponentDto project = dbTester.components().insertPublicProject();
     userSessionRule.registerComponents(project);
     RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
@@ -565,13 +566,16 @@ public class ShowActionTest {
       .setLocations(DbIssues.Locations.newBuilder()
         .setTextRange(DbCommons.TextRange.newBuilder().build())
         .build()));
-    ChangelogLoadingContext changelogLoadingContext = Mockito.mock(ChangelogLoadingContext.class);
+    FormattingContext formattingContext = Mockito.mock(FormattingContext.class);
     List<Common.Changelog> changelog = IntStream.range(0, 1 + new Random().nextInt(12))
       .mapToObj(i -> Common.Changelog.newBuilder().setUser("u" + i).build())
       .collect(Collectors.toList());
-    when(issueChangelog.newChangelogLoadingContext(any(), any(), anySet(), anySet())).thenReturn(changelogLoadingContext);
-    when(issueChangelog.formatChangelog(any(), eq(changelogLoadingContext)))
-      .thenReturn(changelog.stream());
+    List<Common.Comment> comments = IntStream.range(0, 1 + new Random().nextInt(12))
+      .mapToObj(i -> Common.Comment.newBuilder().setKey("u" + i).build())
+      .collect(Collectors.toList());
+    when(issueChangeSupport.newFormattingContext(any(), any(), any(), anySet(), anySet())).thenReturn(formattingContext);
+    when(issueChangeSupport.formatChangelog(any(), any())).thenReturn(changelog.stream());
+    when(issueChangeSupport.formatComments(any(), any(), any())).thenReturn(comments.stream());
 
     Hotspots.ShowWsResponse response = newRequest(hotspot)
       .executeProtobuf(Hotspots.ShowWsResponse.class);
@@ -579,10 +583,15 @@ public class ShowActionTest {
     assertThat(response.getChangelogList())
       .extracting(Common.Changelog::getUser)
       .containsExactly(changelog.stream().map(Common.Changelog::getUser).toArray(String[]::new));
-    verify(issueChangelog).newChangelogLoadingContext(any(DbSession.class),
-      argThat(new IssueDtoArgumentMatcher(hotspot)),
+    assertThat(response.getCommentList())
+      .extracting(Common.Comment::getKey)
+      .containsExactly(comments.stream().map(Common.Comment::getKey).toArray(String[]::new));
+    verify(issueChangeSupport).newFormattingContext(any(DbSession.class),
+      argThat(new IssueDtoSetArgumentMatcher(hotspot)),
+      eq(Load.ALL),
       eq(Collections.emptySet()), eq(ImmutableSet.of(project, file)));
-    verify(issueChangelog).formatChangelog(any(DbSession.class), eq(changelogLoadingContext));
+    verify(issueChangeSupport).formatChangelog(argThat(new IssueDtoArgumentMatcher(hotspot)), eq(formattingContext));
+    verify(issueChangeSupport).formatComments(argThat(new IssueDtoArgumentMatcher(hotspot)), any(Common.Comment.Builder.class), eq(formattingContext));
   }
 
   public void verifyRule(Hotspots.Rule wsRule, RuleDefinitionDto dto) {
@@ -636,6 +645,24 @@ public class ShowActionTest {
     return ruleDefinition;
   }
 
+  private static class IssueDtoSetArgumentMatcher implements ArgumentMatcher<Set<IssueDto>> {
+    private final IssueDto expected;
+
+    private IssueDtoSetArgumentMatcher(IssueDto expected) {
+      this.expected = expected;
+    }
+
+    @Override
+    public boolean matches(Set<IssueDto> argument) {
+      return argument != null && argument.size() == 1 && argument.iterator().next().getKey().equals(expected.getKey());
+    }
+
+    @Override
+    public String toString() {
+      return "Set<IssueDto>[" + expected.getKey() + "]";
+    }
+  }
+
   private static class IssueDtoArgumentMatcher implements ArgumentMatcher<IssueDto> {
     private final IssueDto expected;
 
@@ -647,6 +674,11 @@ public class ShowActionTest {
     public boolean matches(IssueDto argument) {
       return argument != null && argument.getKey().equals(expected.getKey());
     }
+
+    @Override
+    public String toString() {
+      return "IssueDto[key=" + expected.getKey() + "]";
+    }
   }
 
 }
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/IssueChangeWSSupportTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/IssueChangeWSSupportTest.java
new file mode 100644 (file)
index 0000000..eafb637
--- /dev/null
@@ -0,0 +1,606 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * 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 org.sonar.server.issue;
+
+import com.google.common.collect.ImmutableSet;
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.api.utils.System2;
+import org.sonar.core.issue.FieldDiffs;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.ComponentTesting;
+import org.sonar.db.issue.IssueChangeDto;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.issue.IssueTesting;
+import org.sonar.db.organization.OrganizationTesting;
+import org.sonar.db.user.UserDto;
+import org.sonar.markdown.Markdown;
+import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext;
+import org.sonar.server.issue.IssueChangeWSSupport.Load;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonarqube.ws.Common.Changelog;
+import org.sonarqube.ws.Common.Comment;
+
+import static java.util.Collections.emptySet;
+import static java.util.Collections.singleton;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.db.issue.IssueChangeDto.TYPE_COMMENT;
+import static org.sonar.db.issue.IssueChangeDto.TYPE_FIELD_CHANGE;
+
+@RunWith(DataProviderRunner.class)
+public class IssueChangeWSSupportTest {
+  private static final Random RANDOM = new Random();
+
+  @Rule
+  public DbTester dbTester = DbTester.create(System2.INSTANCE);
+  @Rule
+  public UserSessionRule userSessionRule = UserSessionRule.standalone();
+
+  private DbClient dbClient = dbTester.getDbClient();
+  private AvatarResolverImpl avatarResolver = new AvatarResolverImpl();
+
+  private IssueChangeWSSupport underTest = new IssueChangeWSSupport(dbClient, avatarResolver, userSessionRule);
+
+  @Test
+  public void newFormattingContext_with_Load_CHANGE_LOG_loads_only_changelog() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    List<IssueChangeDto> comments = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newComment(issue).setKey("comment_" + i))
+      .collect(Collectors.toList());
+    List<IssueChangeDto> fieldChanges = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newFieldChange(issue)
+        .setChangeData(new FieldDiffs()
+          .setDiff("f_change_" + i, null, null)
+          .toEncodedString()))
+      .collect(Collectors.toList());
+    insertInRandomOrder(comments, fieldChanges);
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.CHANGE_LOG);
+
+    assertThat(formattingContext.getChanges(issue))
+      .extracting(FieldDiffs::toEncodedString)
+      .containsExactlyInAnyOrder(fieldChanges.stream().map(t -> t.toFieldDiffs().toEncodedString()).toArray(String[]::new));
+    assertThat(formattingContext.getComments(issue)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_with_Load_COMMENTS_loads_only_comments() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    List<IssueChangeDto> comments = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newComment(issue).setKey("comment_" + i))
+      .collect(Collectors.toList());
+    List<IssueChangeDto> fieldChanges = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newFieldChange(issue)
+        .setChangeData(new FieldDiffs()
+          .setDiff("f_change_" + i, null, null)
+          .toEncodedString()))
+      .collect(Collectors.toList());
+    insertInRandomOrder(comments, fieldChanges);
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.COMMENTS);
+
+    assertThat(formattingContext.getComments(issue))
+      .extracting(IssueChangeDto::getKey)
+      .containsExactlyInAnyOrder(comments.stream().map(IssueChangeDto::getKey).toArray(String[]::new));
+    assertThat(formattingContext.getChanges(issue)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_with_Load_ALL_loads_changelog_and_comments() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    List<IssueChangeDto> comments = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newComment(issue).setKey("comment_" + i))
+      .collect(Collectors.toList());
+    List<IssueChangeDto> fieldChanges = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newFieldChange(issue)
+        .setChangeData(new FieldDiffs()
+          .setDiff("f_change_" + i, null, null)
+          .toEncodedString()))
+      .collect(Collectors.toList());
+    insertInRandomOrder(comments, fieldChanges);
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.ALL);
+
+    assertThat(formattingContext.getComments(issue))
+      .extracting(IssueChangeDto::getKey)
+      .containsExactlyInAnyOrder(comments.stream().map(IssueChangeDto::getKey).toArray(String[]::new));
+    assertThat(formattingContext.getComments(issue))
+      .extracting(IssueChangeDto::getKey)
+      .containsExactlyInAnyOrder(comments.stream().map(IssueChangeDto::getKey).toArray(String[]::new));
+  }
+
+  @Test
+  public void newFormattingContext_with_load_CHANGE_LOG_loads_users_of_field_changes() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    UserDto user3 = dbTester.users().insertUser();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto fieldChangeUser1 = newFieldChange(issue)
+      .setUserUuid(user1.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_1", null, null).toEncodedString());
+    IssueChangeDto fieldChangeUser2a = newFieldChange(issue)
+      .setUserUuid(user2.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_2a", null, null).toEncodedString());
+    IssueChangeDto fieldChangeUser2b = newFieldChange(issue)
+      .setUserUuid(user2.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_2b", null, null).toEncodedString());
+    IssueChangeDto fieldChangeNonExistingUser = newFieldChange(issue)
+      .setUserUuid(uuid)
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_unknown", null, null).toEncodedString());
+    insertInRandomOrder(Arrays.asList(fieldChangeUser1, fieldChangeUser2a, fieldChangeUser2b, fieldChangeNonExistingUser));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.CHANGE_LOG);
+
+    assertThat(formattingContext.getUserByUuid(user1.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user2.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user3.getUuid())).isEmpty();
+    assertThat(formattingContext.getUserByUuid(uuid)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_with_load_COMMENTS_loads_users_of_comments() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    UserDto user3 = dbTester.users().insertUser();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto issueChangeUser1 = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser2a = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeUser2b = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeNonExistingUser = newComment(issue).setUserUuid(uuid);
+    insertInRandomOrder(Arrays.asList(issueChangeUser1, issueChangeUser2a, issueChangeUser2b, issueChangeNonExistingUser));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.COMMENTS);
+
+    assertThat(formattingContext.getUserByUuid(user1.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user2.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user3.getUuid())).isEmpty();
+    assertThat(formattingContext.getUserByUuid(uuid)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_with_load_ALL_loads_users_of_fieldChanges_and_comments() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    UserDto user3 = dbTester.users().insertUser();
+    UserDto user4 = dbTester.users().insertUser();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto issueChangeUser1 = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser2a = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeUser2b = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeNonExistingUser = newComment(issue).setUserUuid(uuid);
+    IssueChangeDto fieldChangeUser1 = newFieldChange(issue)
+      .setUserUuid(user1.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_1", null, null).toEncodedString());
+    IssueChangeDto fieldChangeUser4 = newFieldChange(issue)
+      .setUserUuid(user4.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_4", null, null).toEncodedString());
+    IssueChangeDto fieldChangeNonExistingUser = newFieldChange(issue)
+      .setUserUuid(uuid)
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_unknown", null, null).toEncodedString());
+    insertInRandomOrder(Arrays.asList(issueChangeUser1, issueChangeUser2a, issueChangeUser2b, issueChangeNonExistingUser),
+      Arrays.asList(fieldChangeUser1, fieldChangeUser4, fieldChangeNonExistingUser));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.ALL);
+
+    assertThat(formattingContext.getUserByUuid(user1.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user2.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(user3.getUuid())).isEmpty();
+    assertThat(formattingContext.getUserByUuid(user4.getUuid())).isNotEmpty();
+    assertThat(formattingContext.getUserByUuid(uuid)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_with_load_CHANGE_LOG_loads_files_of_file_fieldChanges() {
+    newFormattingContext_loads_files_of_file_fieldChanges(Load.CHANGE_LOG);
+  }
+
+  @Test
+  public void newFormattingContext_with_load_ALL_loads_files_of_file_fieldChanges() {
+    newFormattingContext_loads_files_of_file_fieldChanges(Load.ALL);
+  }
+
+  private void newFormattingContext_loads_files_of_file_fieldChanges(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    ComponentDto file1 = insertFile();
+    ComponentDto file2 = insertFile();
+    ComponentDto file3 = insertFile();
+    ComponentDto file4 = insertFile();
+    ComponentDto file5 = insertFile();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto fileChangeFile1 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file1.uuid(), null).toEncodedString());
+    IssueChangeDto fileChangeFile2 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file2.uuid(), null).toEncodedString());
+    IssueChangeDto fileChangeFile3 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", null, file3.uuid()).toEncodedString());
+    IssueChangeDto fileChangeFile4 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file4.uuid(), file4.uuid()).toEncodedString());
+    IssueChangeDto fileChangeNotExistingFile = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", uuid, uuid).toEncodedString());
+    insertInRandomOrder(Arrays.asList(fileChangeFile1, fileChangeFile2, fileChangeFile3, fileChangeFile4, fileChangeNotExistingFile));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load);
+
+    assertThat(formattingContext.getFileByUuid(file1.uuid())).isNotEmpty();
+    assertThat(formattingContext.getFileByUuid(file2.uuid())).isNotEmpty();
+    assertThat(formattingContext.getFileByUuid(file3.uuid())).isNotEmpty();
+    assertThat(formattingContext.getFileByUuid(file4.uuid())).isNotEmpty();
+    assertThat(formattingContext.getFileByUuid(file5.uuid())).isEmpty();
+    assertThat(formattingContext.getFileByUuid(uuid)).isEmpty();
+  }
+
+  @Test
+  public void newFormattingContext_does_not_load_preloaded_users_from_DB() {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    UserDto user3 = dbTester.users().insertUser();
+    UserDto user4 = dbTester.users().insertUser();
+    IssueChangeDto issueChangeUser1 = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser2 = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto fieldChangeUser1 = newFieldChange(issue)
+      .setUserUuid(user1.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_1", null, null).toEncodedString());
+    IssueChangeDto fieldChangeUser3 = newFieldChange(issue)
+      .setUserUuid(user3.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_3", null, null).toEncodedString());
+    IssueChangeDto fieldChangeUser4 = newFieldChange(issue)
+      .setUserUuid(user4.getUuid())
+      .setChangeData(new FieldDiffs().setDiff("f_change_user_4", null, null).toEncodedString());
+    insertInRandomOrder(Arrays.asList(issueChangeUser1, issueChangeUser2),
+      Arrays.asList(fieldChangeUser1, fieldChangeUser3, fieldChangeUser4));
+    user1.setEmail("post_insert_changed" + user1.getUuid());
+    user2.setEmail("post_insert_changed" + user2.getUuid());
+    user3.setEmail("post_insert_changed" + user3.getUuid());
+    user4.setEmail("post_insert_changed" + user4.getUuid());
+
+    // no users are preloaded
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.ALL,
+      emptySet(), emptySet());
+    assertThat(formattingContext.getUserByUuid(user1.getUuid()).get().getEmail()).isNotEqualTo(user1.getEmail());
+    assertThat(formattingContext.getUserByUuid(user2.getUuid()).get().getEmail()).isNotEqualTo(user2.getEmail());
+    assertThat(formattingContext.getUserByUuid(user3.getUuid()).get().getEmail()).isNotEqualTo(user3.getEmail());
+    assertThat(formattingContext.getUserByUuid(user4.getUuid()).get().getEmail()).isNotEqualTo(user4.getEmail());
+
+    // some users are preloaded
+    formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.ALL,
+      ImmutableSet.of(user1, user4), emptySet());
+    assertThat(formattingContext.getUserByUuid(user1.getUuid()).get().getEmail()).isEqualTo(user1.getEmail());
+    assertThat(formattingContext.getUserByUuid(user2.getUuid()).get().getEmail()).isNotEqualTo(user2.getEmail());
+    assertThat(formattingContext.getUserByUuid(user3.getUuid()).get().getEmail()).isNotEqualTo(user3.getEmail());
+    assertThat(formattingContext.getUserByUuid(user4.getUuid()).get().getEmail()).isEqualTo(user4.getEmail());
+
+    // all users are preloaded
+    formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), Load.ALL,
+      ImmutableSet.of(user1, user2, user3, user4), emptySet());
+    assertThat(formattingContext.getUserByUuid(user1.getUuid()).get().getEmail()).isEqualTo(user1.getEmail());
+    assertThat(formattingContext.getUserByUuid(user2.getUuid()).get().getEmail()).isEqualTo(user2.getEmail());
+    assertThat(formattingContext.getUserByUuid(user3.getUuid()).get().getEmail()).isEqualTo(user3.getEmail());
+    assertThat(formattingContext.getUserByUuid(user4.getUuid()).get().getEmail()).isEqualTo(user4.getEmail());
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrChangelog")
+  public void newFormattingContext_does_not_load_preloaded_files_from_DB(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    ComponentDto file1 = insertFile();
+    ComponentDto file2 = insertFile();
+    ComponentDto file3 = insertFile();
+    ComponentDto file4 = insertFile();
+    IssueChangeDto fileChangeFile1 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file1.uuid(), null).toEncodedString());
+    IssueChangeDto fileChangeFile2 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file2.uuid(), null).toEncodedString());
+    IssueChangeDto fileChangeFile3 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", null, file3.uuid()).toEncodedString());
+    IssueChangeDto fileChangeFile4 = newFieldChange(issue)
+      .setChangeData(new FieldDiffs().setDiff("file", file4.uuid(), file4.uuid()).toEncodedString());
+    insertInRandomOrder(Arrays.asList(fileChangeFile1, fileChangeFile2, fileChangeFile3, fileChangeFile4));
+    file1.setName("preloaded_name" + file1.uuid());
+    file2.setName("preloaded_name" + file2.uuid());
+    file3.setName("preloaded_name" + file3.uuid());
+    file4.setName("preloaded_name" + file4.uuid());
+
+    // no files are preloaded
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load,
+      emptySet(), emptySet());
+
+    assertThat(formattingContext.getFileByUuid(file1.uuid()).get().name()).isNotEqualTo(file1.name());
+    assertThat(formattingContext.getFileByUuid(file2.uuid()).get().name()).isNotEqualTo(file2.name());
+    assertThat(formattingContext.getFileByUuid(file3.uuid()).get().name()).isNotEqualTo(file3.name());
+    assertThat(formattingContext.getFileByUuid(file4.uuid()).get().name()).isNotEqualTo(file4.name());
+
+    // some files are preloaded
+    formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load,
+      emptySet(), ImmutableSet.of(file2, file3));
+
+    assertThat(formattingContext.getFileByUuid(file1.uuid()).get().name()).isNotEqualTo(file1.name());
+    assertThat(formattingContext.getFileByUuid(file2.uuid()).get().name()).isEqualTo(file2.name());
+    assertThat(formattingContext.getFileByUuid(file3.uuid()).get().name()).isEqualTo(file3.name());
+    assertThat(formattingContext.getFileByUuid(file4.uuid()).get().name()).isNotEqualTo(file4.name());
+
+    // all files are preloaded
+    formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load,
+      emptySet(), ImmutableSet.of(file1, file2, file3, file4));
+
+    assertThat(formattingContext.getFileByUuid(file1.uuid()).get().name()).isEqualTo(file1.name());
+    assertThat(formattingContext.getFileByUuid(file2.uuid()).get().name()).isEqualTo(file2.name());
+    assertThat(formattingContext.getFileByUuid(file3.uuid()).get().name()).isEqualTo(file3.name());
+    assertThat(formattingContext.getFileByUuid(file4.uuid()).get().name()).isEqualTo(file4.name());
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrComments")
+  public void newFormattingContext_comments_without_userUuid_or_with_unknown_userUuid_are_not_updatable(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto issueChangeUser1 = newComment(issue);
+    IssueChangeDto issueChangeUserUnknown = newComment(issue).setUserUuid(uuid);
+    insertInRandomOrder(Arrays.asList(issueChangeUser1, issueChangeUserUnknown));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load);
+
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser1)).isFalse();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUserUnknown)).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrComments")
+  public void newFormattingContext_comments_with_userUuid_are_not_updatable_if_no_user_is_logged_in(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto issueChangeUser1 = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser2 = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeUserUnknown = newComment(issue).setUserUuid(uuid);
+    insertInRandomOrder(Arrays.asList(issueChangeUser1, issueChangeUser2, issueChangeUserUnknown));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load);
+
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser1)).isFalse();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser2)).isFalse();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUserUnknown)).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrComments")
+  public void newFormattingContext_only_comments_of_logged_in_user_are_updatable(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser();
+    userSessionRule.logIn(user2);
+    String uuid = randomAlphabetic(30);
+    IssueChangeDto issueChangeUser1a = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser1b = newComment(issue).setUserUuid(user1.getUuid());
+    IssueChangeDto issueChangeUser2a = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeUser2b = newComment(issue).setUserUuid(user2.getUuid());
+    IssueChangeDto issueChangeUserUnknown = newComment(issue).setUserUuid(uuid);
+    insertInRandomOrder(Arrays.asList(issueChangeUser1a, issueChangeUser1b, issueChangeUser2a, issueChangeUser2b, issueChangeUserUnknown));
+
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load);
+
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser1a)).isFalse();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser1b)).isFalse();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser2a)).isTrue();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUser2b)).isTrue();
+    assertThat(formattingContext.isUpdatableComment(issueChangeUserUnknown)).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrChangelog")
+  public void formatChangelog_returns_empty_if_context_has_no_changeLog_for_specified_IssueDto(Load load) {
+    IssueDto issue1 = dbTester.issues().insertIssue();
+    IssueDto issue2 = dbTester.issues().insertIssue();
+    List<IssueChangeDto> comments = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newComment(issue1).setKey("comment_" + i))
+      .collect(Collectors.toList());
+    List<IssueChangeDto> fieldChanges = IntStream.range(0, 1 + RANDOM.nextInt(20))
+      .mapToObj(i -> newFieldChange(issue1)
+        .setChangeData(new FieldDiffs()
+          .setDiff("f_change_" + i, null, null)
+          .toEncodedString()))
+      .collect(Collectors.toList());
+    insertInRandomOrder(comments, fieldChanges);
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue1), Load.CHANGE_LOG);
+
+    assertThat(underTest.formatChangelog(issue2, formattingContext)).isEmpty();
+    assertThat(underTest.formatChangelog(issue1, formattingContext)).isNotEmpty();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrChangelog")
+  public void formatChangelog_returns_field_diff_details(Load load) {
+    IssueDto issue1 = dbTester.issues().insertIssue();
+    int createdAt = 2_333_999;
+    IssueChangeDto issueChangeDto = dbTester.issues().insertChange(newFieldChange(issue1)
+      .setIssueChangeCreationDate(createdAt)
+      .setChangeData(new FieldDiffs()
+        .setDiff("f_change_1", "a", "b")
+        .setDiff("f_change_2", "c", null)
+        .setDiff("f_change_3", null, null)
+        .setDiff("f_change_4", null, "e")
+        .toEncodedString()));
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue1), load);
+
+    List<Changelog> wsChangelogList = underTest.formatChangelog(issue1, formattingContext).collect(Collectors.toList());
+    assertThat(wsChangelogList).hasSize(1);
+    Changelog wsChangelog = wsChangelogList.iterator().next();
+    assertThat(wsChangelog.getCreationDate()).isEqualTo(formatDateTime(createdAt));
+    assertThat(wsChangelog.getDiffsList()).hasSize(4);
+    assertThat(wsChangelog.getDiffsList().get(0).getKey()).isEqualTo("f_change_1");
+    assertThat(wsChangelog.getDiffsList().get(0).getOldValue()).isEqualTo("a");
+    assertThat(wsChangelog.getDiffsList().get(0).getNewValue()).isEqualTo("b");
+    assertThat(wsChangelog.getDiffsList().get(1).getKey()).isEqualTo("f_change_2");
+    assertThat(wsChangelog.getDiffsList().get(1).getOldValue()).isEqualTo("c");
+    assertThat(wsChangelog.getDiffsList().get(1).hasNewValue()).isFalse();
+    assertThat(wsChangelog.getDiffsList().get(2).getKey()).isEqualTo("f_change_3");
+    assertThat(wsChangelog.getDiffsList().get(2).hasOldValue()).isFalse();
+    assertThat(wsChangelog.getDiffsList().get(2).hasNewValue()).isFalse();
+    assertThat(wsChangelog.getDiffsList().get(3).getKey()).isEqualTo("f_change_4");
+    assertThat(wsChangelog.getDiffsList().get(3).hasOldValue()).isFalse();
+    assertThat(wsChangelog.getDiffsList().get(3).getNewValue()).isEqualTo("e");
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrChangelog")
+  public void formatChangelog_returns_user_details_if_exists(Load load) {
+    IssueDto issue1 = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser();
+    UserDto user2 = dbTester.users().insertUser(t -> t.setActive(false));
+    String uuid = randomAlphabetic(22);
+    dbTester.issues().insertChange(newFieldChange(issue1)
+      .setUserUuid(user1.getUuid())
+      .setChangeData(new FieldDiffs()
+        .setDiff("f_change_1", "a", "b")
+        .toEncodedString()));
+    dbTester.issues().insertChange(newFieldChange(issue1)
+      .setUserUuid(uuid)
+      .setChangeData(new FieldDiffs()
+        .setDiff("f_change_2", "a", "b")
+        .toEncodedString()));
+    dbTester.issues().insertChange(newFieldChange(issue1)
+      .setUserUuid(user2.getUuid())
+      .setChangeData(new FieldDiffs()
+        .setDiff("f_change_3", "a", "b")
+        .toEncodedString()));
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue1), load);
+
+    List<Changelog> wsChangelogList = underTest.formatChangelog(issue1, formattingContext).collect(Collectors.toList());
+    assertThat(wsChangelogList)
+      .extracting(Changelog::hasUser, t -> t.getDiffsList().iterator().next().getKey())
+      .containsExactlyInAnyOrder(
+        tuple(true, "f_change_1"),
+        tuple(false, "f_change_2"),
+        tuple(true, "f_change_3"));
+    assertThat(wsChangelogList.stream().filter(Changelog::hasUser))
+      .extracting(Changelog::getUser, Changelog::getUserName, Changelog::getIsUserActive, Changelog::getAvatar)
+      .containsExactlyInAnyOrder(
+        tuple(user1.getLogin(), user1.getName(), true, avatarResolver.create(user1)),
+        tuple(user2.getLogin(), user2.getName(), false, avatarResolver.create(user2)));
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrChangelog")
+  public void formatChangelog_returns_no_avatar_if_email_is_not_set(Load load) {
+    IssueDto issue1 = dbTester.issues().insertIssue();
+    UserDto user1 = dbTester.users().insertUser(t -> t.setEmail(null));
+    dbTester.issues().insertChange(newFieldChange(issue1)
+      .setUserUuid(user1.getUuid())
+      .setChangeData(new FieldDiffs()
+        .setDiff("f_change_1", "a", "b")
+        .toEncodedString()));
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue1), load);
+
+    List<Changelog> wsChangelogList = underTest.formatChangelog(issue1, formattingContext).collect(Collectors.toList());
+    assertThat(wsChangelogList).hasSize(1);
+    assertThat(wsChangelogList.iterator().next().hasAvatar()).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrComments")
+  public void formatComments_returns_empty_if_context_has_no_comment_for_specified_IssueDto(Load load) {
+    IssueDto issue1 = dbTester.issues().insertIssue();
+    IssueDto issue2 = dbTester.issues().insertIssue();
+    IntStream.range(0, 1 + RANDOM.nextInt(22))
+      .forEach(t -> dbTester.issues().insertChange(newComment(issue1)));
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue1), load);
+
+    assertThat(underTest.formatComments(issue2, Comment.newBuilder(), formattingContext)).isEmpty();
+    assertThat(underTest.formatComments(issue1, Comment.newBuilder(), formattingContext)).isNotEmpty();
+  }
+
+  @Test
+  @UseDataProvider("loadAllOrComments")
+  public void formatComments_returns_comment_markdown_and_html_when_available(Load load) {
+    IssueDto issue = dbTester.issues().insertIssue();
+    IssueChangeDto withText = dbTester.issues().insertChange(newComment(issue).setChangeData("* foo"));
+    IssueChangeDto noText = dbTester.issues().insertChange(newComment(issue).setChangeData(null));
+    FormattingContext formattingContext = underTest.newFormattingContext(dbTester.getSession(), singleton(issue), load);
+
+    List<Comment> comments = underTest.formatComments(issue, Comment.newBuilder(), formattingContext).collect(Collectors.toList());
+    assertThat(comments)
+      .extracting(Comment::getKey, Comment::hasMarkdown, Comment::hasHtmlText)
+      .containsExactlyInAnyOrder(
+        tuple(withText.getKey(), true, true),
+        tuple(noText.getKey(), false, false)
+      );
+    assertThat(comments.stream().filter(Comment::hasHtmlText))
+      .extracting(Comment::getMarkdown, Comment::getHtmlText)
+      .containsOnly(tuple(withText.getChangeData(), Markdown.convertToHtml(withText.getChangeData())));
+  }
+
+  @DataProvider
+  public static Object[][] loadAllOrChangelog() {
+    return new Object[][] {
+      {Load.ALL},
+      {Load.CHANGE_LOG}
+    };
+  }
+
+  @DataProvider
+  public static Object[][] loadAllOrComments() {
+    return new Object[][] {
+      {Load.ALL},
+      {Load.COMMENTS}
+    };
+  }
+
+  private ComponentDto insertFile() {
+    return dbTester.components().insertComponent(ComponentTesting.newFileDto(ComponentTesting.newPublicProjectDto(OrganizationTesting.newOrganizationDto())));
+  }
+
+  private static IssueChangeDto newComment(IssueDto issue) {
+    return IssueTesting.newIssuechangeDto(issue).setChangeType(TYPE_COMMENT);
+  }
+
+  private static IssueChangeDto newFieldChange(IssueDto issue) {
+    return IssueTesting.newIssuechangeDto(issue).setChangeType(TYPE_FIELD_CHANGE);
+  }
+
+  @SafeVarargs
+  private final void insertInRandomOrder(Collection<IssueChangeDto>... changesLists) {
+    List<IssueChangeDto> all = new ArrayList<>();
+    Arrays.stream(changesLists).forEach(all::addAll);
+    Collections.shuffle(all);
+    all.forEach(i -> dbTester.issues().insertChange(i));
+  }
+}
index aa447b0402e51e3eb453a111ec190d07a5e824c0..5a9f7cce01280cdf8c68dcef212561f4c39985d6 100644 (file)
@@ -38,7 +38,7 @@ import org.sonar.db.user.UserDto;
 import org.sonar.db.user.UserTesting;
 import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.issue.AvatarResolverImpl;
-import org.sonar.server.issue.IssueChangelog;
+import org.sonar.server.issue.IssueChangeWSSupport;
 import org.sonar.server.issue.IssueFinder;
 import org.sonar.server.tester.UserSessionRule;
 import org.sonar.server.ws.TestRequest;
@@ -71,8 +71,8 @@ public class ChangelogActionTest {
   private ComponentDto project;
   private ComponentDto file;
   private IssueFinder issueFinder = new IssueFinder(db.getDbClient(), userSession);
-  private IssueChangelog issueChangelog = new IssueChangelog(db.getDbClient(), new AvatarResolverImpl());
-  private ChangelogAction underTest = new ChangelogAction(db.getDbClient(), issueFinder, userSession, issueChangelog);
+  private IssueChangeWSSupport issueChangeSupport = new IssueChangeWSSupport(db.getDbClient(), new AvatarResolverImpl(), userSession);
+  private ChangelogAction underTest = new ChangelogAction(db.getDbClient(), issueFinder, userSession, issueChangeSupport);
   private WsActionTester tester = new WsActionTester(underTest);
 
   @Before
index 1606042472faa412cb266cc382ee155d7970255f..51c3133b0ecf217bbee2bda3a3db8663031511b6 100644 (file)
@@ -65,6 +65,7 @@ message ShowWsResponse {
   optional string updateDate = 12;
   optional sonarqube.ws.commons.TextRange textRange = 13;
   repeated sonarqube.ws.commons.Changelog changelog = 14;
+  repeated sonarqube.ws.commons.Comment comment = 15;
 }
 
 message Component {