You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

IssueChangeWSSupport.java 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2023 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.server.issue;
  21. import com.google.common.base.Strings;
  22. import com.google.common.collect.ImmutableSet;
  23. import com.google.common.collect.Multimap;
  24. import com.google.common.collect.Sets;
  25. import java.io.Serializable;
  26. import java.util.Collection;
  27. import java.util.Comparator;
  28. import java.util.Date;
  29. import java.util.List;
  30. import java.util.Map;
  31. import java.util.Objects;
  32. import java.util.Optional;
  33. import java.util.Set;
  34. import java.util.function.Function;
  35. import java.util.stream.Collectors;
  36. import java.util.stream.Stream;
  37. import javax.annotation.Nullable;
  38. import javax.annotation.concurrent.Immutable;
  39. import org.sonar.api.utils.DateUtils;
  40. import org.sonar.core.issue.FieldDiffs;
  41. import org.sonar.core.util.stream.MoreCollectors;
  42. import org.sonar.db.DbClient;
  43. import org.sonar.db.DbSession;
  44. import org.sonar.db.component.ComponentDto;
  45. import org.sonar.db.issue.IssueChangeDto;
  46. import org.sonar.db.issue.IssueDto;
  47. import org.sonar.db.user.UserDto;
  48. import org.sonar.markdown.Markdown;
  49. import org.sonar.server.common.avatar.AvatarResolver;
  50. import org.sonar.server.user.UserSession;
  51. import org.sonarqube.ws.Common;
  52. import static com.google.common.base.Strings.emptyToNull;
  53. import static java.util.Collections.emptyMap;
  54. import static java.util.Optional.empty;
  55. import static java.util.Optional.ofNullable;
  56. import static org.sonar.api.utils.DateUtils.formatDateTime;
  57. import static org.sonar.db.issue.IssueChangeDto.TYPE_COMMENT;
  58. import static org.sonar.db.issue.IssueChangeDto.TYPE_FIELD_CHANGE;
  59. import static org.sonar.server.issue.IssueFieldsSetter.FILE;
  60. import static org.sonar.server.issue.IssueFieldsSetter.TECHNICAL_DEBT;
  61. public class IssueChangeWSSupport {
  62. private static final String EFFORT_CHANGELOG_KEY = "effort";
  63. private final DbClient dbClient;
  64. private final AvatarResolver avatarFactory;
  65. private final UserSession userSession;
  66. public IssueChangeWSSupport(DbClient dbClient, AvatarResolver avatarFactory, UserSession userSession) {
  67. this.dbClient = dbClient;
  68. this.avatarFactory = avatarFactory;
  69. this.userSession = userSession;
  70. }
  71. public enum Load {
  72. CHANGE_LOG, COMMENTS, ALL
  73. }
  74. public interface FormattingContext {
  75. List<FieldDiffs> getChanges(IssueDto dto);
  76. List<IssueChangeDto> getComments(IssueDto dto);
  77. Set<UserDto> getUsers();
  78. Optional<UserDto> getUserByUuid(@Nullable String uuid);
  79. Optional<ComponentDto> getFileByUuid(@Nullable String uuid);
  80. boolean isUpdatableComment(IssueChangeDto comment);
  81. }
  82. public FormattingContext newFormattingContext(DbSession dbSession, Set<IssueDto> dtos, Load load) {
  83. return newFormattingContext(dbSession, dtos, load, Set.of(), Set.of());
  84. }
  85. public FormattingContext newFormattingContext(DbSession dbSession, Set<IssueDto> dtos, Load load, Set<UserDto> preloadedUsers, Set<ComponentDto> preloadedComponents) {
  86. Set<String> issueKeys = dtos.stream().map(IssueDto::getKey).collect(Collectors.toSet());
  87. List<IssueChangeDto> changes = List.of();
  88. List<IssueChangeDto> comments = List.of();
  89. switch (load) {
  90. case CHANGE_LOG:
  91. changes = dbClient.issueChangeDao().selectByTypeAndIssueKeys(dbSession, issueKeys, TYPE_FIELD_CHANGE);
  92. break;
  93. case COMMENTS:
  94. comments = dbClient.issueChangeDao().selectByTypeAndIssueKeys(dbSession, issueKeys, TYPE_COMMENT);
  95. break;
  96. case ALL:
  97. List<IssueChangeDto> all = dbClient.issueChangeDao().selectByIssueKeys(dbSession, issueKeys);
  98. changes = all.stream()
  99. .filter(t -> TYPE_FIELD_CHANGE.equals(t.getChangeType()))
  100. .toList();
  101. comments = all.stream()
  102. .filter(t -> TYPE_COMMENT.equals(t.getChangeType()))
  103. .toList();
  104. break;
  105. default:
  106. throw new IllegalStateException("Unsupported Load value:" + load);
  107. }
  108. Map<String, List<FieldDiffs>> changesByRuleKey = indexAndSort(changes, IssueChangeDto::toFieldDiffs, Comparator.comparing(FieldDiffs::creationDate));
  109. Map<String, List<IssueChangeDto>> commentsByIssueKey = indexAndSort(comments, t -> t, Comparator.comparing(IssueChangeDto::getIssueChangeCreationDate));
  110. Map<String, UserDto> usersByUuid = loadUsers(dbSession, changesByRuleKey, commentsByIssueKey, preloadedUsers);
  111. Map<String, ComponentDto> filesByUuid = loadFiles(dbSession, changesByRuleKey, preloadedComponents);
  112. Map<String, Boolean> updatableCommentByKey = loadUpdatableFlag(commentsByIssueKey);
  113. return new FormattingContextImpl(changesByRuleKey, commentsByIssueKey, usersByUuid, filesByUuid, updatableCommentByKey);
  114. }
  115. private static <T> Map<String, List<T>> indexAndSort(List<IssueChangeDto> changes, Function<IssueChangeDto, T> transform, Comparator<T> sortingComparator) {
  116. Multimap<String, IssueChangeDto> unordered = changes.stream()
  117. .collect(MoreCollectors.index(IssueChangeDto::getIssueKey, t -> t));
  118. return unordered.asMap().entrySet().stream()
  119. .collect(Collectors.toMap(Map.Entry::getKey, t -> t.getValue().stream()
  120. .map(transform)
  121. .sorted(sortingComparator)
  122. .toList()));
  123. }
  124. private Map<String, UserDto> loadUsers(DbSession dbSession, Map<String, List<FieldDiffs>> changesByRuleKey,
  125. Map<String, List<IssueChangeDto>> commentsByIssueKey, Set<UserDto> preloadedUsers) {
  126. Set<String> userUuids = Stream.concat(
  127. changesByRuleKey.values().stream()
  128. .flatMap(Collection::stream)
  129. .map(FieldDiffs::userUuid)
  130. .filter(Optional::isPresent)
  131. .map(Optional::get),
  132. commentsByIssueKey.values().stream()
  133. .flatMap(Collection::stream)
  134. .map(IssueChangeDto::getUserUuid)
  135. )
  136. .filter(Objects::nonNull)
  137. .collect(Collectors.toSet());
  138. if (userUuids.isEmpty()) {
  139. return emptyMap();
  140. }
  141. Set<String> preloadedUserUuids = preloadedUsers.stream().map(UserDto::getUuid).collect(Collectors.toSet());
  142. Set<String> missingUsersUuids = Sets.difference(userUuids, preloadedUserUuids).immutableCopy();
  143. if (missingUsersUuids.isEmpty()) {
  144. return preloadedUsers.stream()
  145. .filter(t -> userUuids.contains(t.getUuid()))
  146. .collect(Collectors.toMap(UserDto::getUuid, Function.identity()));
  147. }
  148. return Stream.concat(
  149. preloadedUsers.stream(),
  150. dbClient.userDao().selectByUuids(dbSession, missingUsersUuids).stream())
  151. .filter(t -> userUuids.contains(t.getUuid()))
  152. .collect(Collectors.toMap(UserDto::getUuid, Function.identity()));
  153. }
  154. private Map<String, ComponentDto> loadFiles(DbSession dbSession, Map<String, List<FieldDiffs>> changesByRuleKey, Set<ComponentDto> preloadedComponents) {
  155. Set<String> fileUuids = changesByRuleKey.values().stream()
  156. .flatMap(Collection::stream)
  157. .flatMap(diffs -> {
  158. FieldDiffs.Diff diff = diffs.get(FILE);
  159. if (diff == null) {
  160. return Stream.empty();
  161. }
  162. return Stream.of(toString(diff.newValue()), toString(diff.oldValue()));
  163. })
  164. .map(Strings::emptyToNull)
  165. .filter(Objects::nonNull)
  166. .collect(Collectors.toSet());
  167. if (fileUuids.isEmpty()) {
  168. return emptyMap();
  169. }
  170. Set<String> preloadedFileUuids = preloadedComponents.stream().map(ComponentDto::uuid).collect(Collectors.toSet());
  171. Set<String> missingFileUuids = Sets.difference(fileUuids, preloadedFileUuids).immutableCopy();
  172. if (missingFileUuids.isEmpty()) {
  173. return preloadedComponents.stream()
  174. .filter(t -> fileUuids.contains(t.uuid()))
  175. .collect(Collectors.toMap(ComponentDto::uuid, Function.identity()));
  176. }
  177. return Stream.concat(
  178. preloadedComponents.stream(),
  179. dbClient.componentDao().selectByUuids(dbSession, missingFileUuids).stream())
  180. .filter(t -> fileUuids.contains(t.uuid()))
  181. .collect(Collectors.toMap(ComponentDto::uuid, Function.identity()));
  182. }
  183. private Map<String, Boolean> loadUpdatableFlag(Map<String, List<IssueChangeDto>> commentsByIssueKey) {
  184. if (!userSession.isLoggedIn()) {
  185. return emptyMap();
  186. }
  187. String userUuid = userSession.getUuid();
  188. if (userUuid == null) {
  189. return emptyMap();
  190. }
  191. return commentsByIssueKey.values().stream()
  192. .flatMap(Collection::stream)
  193. .collect(Collectors.toMap(IssueChangeDto::getKey, t -> userUuid.equals(t.getUserUuid())));
  194. }
  195. public Stream<Common.Changelog> formatChangelog(IssueDto dto, FormattingContext formattingContext) {
  196. return formattingContext.getChanges(dto).stream()
  197. .map(toWsChangelog(formattingContext));
  198. }
  199. private Function<FieldDiffs, Common.Changelog> toWsChangelog(FormattingContext formattingContext) {
  200. return change -> {
  201. Common.Changelog.Builder changelogBuilder = Common.Changelog.newBuilder();
  202. changelogBuilder.setCreationDate(formatDateTime(change.creationDate()));
  203. change.userUuid().flatMap(formattingContext::getUserByUuid)
  204. .ifPresent(user -> {
  205. changelogBuilder.setUser(user.getLogin());
  206. changelogBuilder.setIsUserActive(user.isActive());
  207. ofNullable(user.getName()).ifPresent(changelogBuilder::setUserName);
  208. ofNullable(emptyToNull(user.getEmail())).ifPresent(email -> changelogBuilder.setAvatar(avatarFactory.create(user)));
  209. });
  210. change.externalUser().ifPresent(changelogBuilder::setExternalUser);
  211. change.webhookSource().ifPresent(changelogBuilder::setWebhookSource);
  212. change.diffs().entrySet().stream()
  213. .map(toWsDiff(formattingContext))
  214. .forEach(changelogBuilder::addDiffs);
  215. return changelogBuilder.build();
  216. };
  217. }
  218. private static Function<Map.Entry<String, FieldDiffs.Diff>, Common.Changelog.Diff> toWsDiff(FormattingContext formattingContext) {
  219. return diff -> {
  220. FieldDiffs.Diff value = diff.getValue();
  221. Common.Changelog.Diff.Builder diffBuilder = Common.Changelog.Diff.newBuilder();
  222. String key = diff.getKey();
  223. String oldValue = emptyToNull(toString(value.oldValue()));
  224. String newValue = emptyToNull(toString(value.newValue()));
  225. if (key.equals(FILE)) {
  226. diffBuilder.setKey(key);
  227. formattingContext.getFileByUuid(newValue).map(ComponentDto::longName).ifPresent(diffBuilder::setNewValue);
  228. formattingContext.getFileByUuid(oldValue).map(ComponentDto::longName).ifPresent(diffBuilder::setOldValue);
  229. } else {
  230. diffBuilder.setKey(key.equals(TECHNICAL_DEBT) ? EFFORT_CHANGELOG_KEY : key);
  231. ofNullable(newValue).ifPresent(diffBuilder::setNewValue);
  232. ofNullable(oldValue).ifPresent(diffBuilder::setOldValue);
  233. }
  234. return diffBuilder.build();
  235. };
  236. }
  237. public Stream<Common.Comment> formatComments(IssueDto dto, Common.Comment.Builder commentBuilder, FormattingContext formattingContext) {
  238. return formattingContext.getComments(dto).stream()
  239. .map(comment -> {
  240. commentBuilder
  241. .clear()
  242. .setKey(comment.getKey())
  243. .setUpdatable(formattingContext.isUpdatableComment(comment))
  244. .setCreatedAt(DateUtils.formatDateTime(new Date(comment.getIssueChangeCreationDate())));
  245. String markdown = comment.getChangeData();
  246. formattingContext.getUserByUuid(comment.getUserUuid()).ifPresent(user -> commentBuilder.setLogin(user.getLogin()));
  247. if (markdown != null) {
  248. commentBuilder
  249. .setHtmlText(Markdown.convertToHtml(markdown))
  250. .setMarkdown(markdown);
  251. }
  252. return commentBuilder.build();
  253. });
  254. }
  255. private static String toString(@Nullable Serializable serializable) {
  256. if (serializable != null) {
  257. return serializable.toString();
  258. }
  259. return null;
  260. }
  261. @Immutable
  262. public static final class FormattingContextImpl implements FormattingContext {
  263. private final Map<String, List<FieldDiffs>> changesByIssueKey;
  264. private final Map<String, List<IssueChangeDto>> commentsByIssueKey;
  265. private final Map<String, UserDto> usersByUuid;
  266. private final Map<String, ComponentDto> filesByUuid;
  267. private final Map<String, Boolean> updatableCommentByKey;
  268. private FormattingContextImpl(Map<String, List<FieldDiffs>> changesByIssueKey,
  269. Map<String, List<IssueChangeDto>> commentsByIssueKey,
  270. Map<String, UserDto> usersByUuid, Map<String, ComponentDto> filesByUuid,
  271. Map<String, Boolean> updatableCommentByKey) {
  272. this.changesByIssueKey = changesByIssueKey;
  273. this.commentsByIssueKey = commentsByIssueKey;
  274. this.usersByUuid = usersByUuid;
  275. this.filesByUuid = filesByUuid;
  276. this.updatableCommentByKey = updatableCommentByKey;
  277. }
  278. @Override
  279. public List<FieldDiffs> getChanges(IssueDto dto) {
  280. List<FieldDiffs> fieldDiffs = changesByIssueKey.get(dto.getKey());
  281. if (fieldDiffs == null) {
  282. return List.of();
  283. }
  284. return List.copyOf(fieldDiffs);
  285. }
  286. @Override
  287. public List<IssueChangeDto> getComments(IssueDto dto) {
  288. List<IssueChangeDto> comments = commentsByIssueKey.get(dto.getKey());
  289. if (comments == null) {
  290. return List.of();
  291. }
  292. return List.copyOf(comments);
  293. }
  294. @Override
  295. public Set<UserDto> getUsers() {
  296. return ImmutableSet.copyOf(usersByUuid.values());
  297. }
  298. @Override
  299. public Optional<UserDto> getUserByUuid(@Nullable String uuid) {
  300. if (uuid == null) {
  301. return empty();
  302. }
  303. return Optional.ofNullable(usersByUuid.get(uuid));
  304. }
  305. @Override
  306. public Optional<ComponentDto> getFileByUuid(@Nullable String uuid) {
  307. if (uuid == null) {
  308. return empty();
  309. }
  310. return Optional.ofNullable(filesByUuid.get(uuid));
  311. }
  312. @Override
  313. public boolean isUpdatableComment(IssueChangeDto comment) {
  314. Boolean flag = updatableCommentByKey.get(comment.getKey());
  315. return flag != null && flag;
  316. }
  317. }
  318. }