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.

BulkChangeAction.java 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2019 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.ws;
  21. import java.util.Collection;
  22. import java.util.Date;
  23. import java.util.HashMap;
  24. import java.util.HashSet;
  25. import java.util.List;
  26. import java.util.Map;
  27. import java.util.Objects;
  28. import java.util.Optional;
  29. import java.util.Set;
  30. import java.util.function.Consumer;
  31. import java.util.function.Predicate;
  32. import org.sonar.api.issue.DefaultTransitions;
  33. import org.sonar.api.rule.RuleKey;
  34. import org.sonar.api.rule.Severity;
  35. import org.sonar.api.rules.RuleType;
  36. import org.sonar.api.server.ws.Change;
  37. import org.sonar.api.server.ws.Request;
  38. import org.sonar.api.server.ws.Response;
  39. import org.sonar.api.server.ws.WebService;
  40. import org.sonar.api.utils.System2;
  41. import org.sonar.api.utils.log.Logger;
  42. import org.sonar.api.utils.log.Loggers;
  43. import org.sonar.api.web.UserRole;
  44. import org.sonar.core.issue.DefaultIssue;
  45. import org.sonar.core.issue.IssueChangeContext;
  46. import org.sonar.core.util.stream.MoreCollectors;
  47. import org.sonar.db.DbClient;
  48. import org.sonar.db.DbSession;
  49. import org.sonar.db.component.ComponentDto;
  50. import org.sonar.db.issue.IssueDto;
  51. import org.sonar.db.rule.RuleDefinitionDto;
  52. import org.sonar.db.user.UserDto;
  53. import org.sonar.server.issue.Action;
  54. import org.sonar.server.issue.AddTagsAction;
  55. import org.sonar.server.issue.AssignAction;
  56. import org.sonar.server.issue.IssueChangePostProcessor;
  57. import org.sonar.server.issue.WebIssueStorage;
  58. import org.sonar.server.issue.RemoveTagsAction;
  59. import org.sonar.server.issue.notification.IssueChangeNotification;
  60. import org.sonar.server.notification.NotificationManager;
  61. import org.sonar.server.user.UserSession;
  62. import org.sonarqube.ws.Issues;
  63. import static com.google.common.base.Preconditions.checkArgument;
  64. import static com.google.common.base.Preconditions.checkState;
  65. import static com.google.common.collect.ImmutableMap.of;
  66. import static java.lang.String.format;
  67. import static java.util.Objects.requireNonNull;
  68. import static java.util.function.Function.identity;
  69. import static java.util.stream.Collectors.toMap;
  70. import static java.util.stream.Collectors.toSet;
  71. import static org.sonar.api.issue.DefaultTransitions.REOPEN;
  72. import static org.sonar.api.rule.Severity.BLOCKER;
  73. import static org.sonar.api.rules.RuleType.BUG;
  74. import static org.sonar.core.util.Uuids.UUID_EXAMPLE_01;
  75. import static org.sonar.core.util.Uuids.UUID_EXAMPLE_02;
  76. import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
  77. import static org.sonar.server.es.SearchOptions.MAX_LIMIT;
  78. import static org.sonar.server.issue.AbstractChangeTagsAction.TAGS_PARAMETER;
  79. import static org.sonar.server.issue.AssignAction.ASSIGNEE_PARAMETER;
  80. import static org.sonar.server.issue.CommentAction.COMMENT_KEY;
  81. import static org.sonar.server.issue.CommentAction.COMMENT_PROPERTY;
  82. import static org.sonar.server.issue.SetSeverityAction.SET_SEVERITY_KEY;
  83. import static org.sonar.server.issue.SetSeverityAction.SEVERITY_PARAMETER;
  84. import static org.sonar.server.issue.SetTypeAction.SET_TYPE_KEY;
  85. import static org.sonar.server.issue.SetTypeAction.TYPE_PARAMETER;
  86. import static org.sonar.server.issue.TransitionAction.DO_TRANSITION_KEY;
  87. import static org.sonar.server.issue.TransitionAction.TRANSITION_PARAMETER;
  88. import static org.sonar.server.ws.WsUtils.writeProtobuf;
  89. import static org.sonarqube.ws.client.issue.IssuesWsParameters.ACTION_BULK_CHANGE;
  90. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_ADD_TAGS;
  91. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_ASSIGN;
  92. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_COMMENT;
  93. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_DO_TRANSITION;
  94. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_ISSUES;
  95. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_PLAN;
  96. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_REMOVE_TAGS;
  97. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_SEND_NOTIFICATIONS;
  98. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_SET_SEVERITY;
  99. import static org.sonarqube.ws.client.issue.IssuesWsParameters.PARAM_SET_TYPE;
  100. public class BulkChangeAction implements IssuesWsAction {
  101. private static final Logger LOG = Loggers.get(BulkChangeAction.class);
  102. private final System2 system2;
  103. private final UserSession userSession;
  104. private final DbClient dbClient;
  105. private final WebIssueStorage issueStorage;
  106. private final NotificationManager notificationService;
  107. private final List<Action> actions;
  108. private final IssueChangePostProcessor issueChangePostProcessor;
  109. public BulkChangeAction(System2 system2, UserSession userSession, DbClient dbClient, WebIssueStorage issueStorage,
  110. NotificationManager notificationService, List<Action> actions,
  111. IssueChangePostProcessor issueChangePostProcessor) {
  112. this.system2 = system2;
  113. this.userSession = userSession;
  114. this.dbClient = dbClient;
  115. this.issueStorage = issueStorage;
  116. this.notificationService = notificationService;
  117. this.actions = actions;
  118. this.issueChangePostProcessor = issueChangePostProcessor;
  119. }
  120. @Override
  121. public void define(WebService.NewController context) {
  122. WebService.NewAction action = context.createAction(ACTION_BULK_CHANGE)
  123. .setDescription("Bulk change on issues.<br/>" +
  124. "Requires authentication.")
  125. .setSince("3.7")
  126. .setChangelog(
  127. new Change("6.3", "'actions' parameter is ignored"))
  128. .setHandler(this)
  129. .setResponseExample(getClass().getResource("bulk_change-example.json"))
  130. .setPost(true);
  131. action.createParam(PARAM_ISSUES)
  132. .setDescription("Comma-separated list of issue keys")
  133. .setRequired(true)
  134. .setExampleValue(UUID_EXAMPLE_01 + "," + UUID_EXAMPLE_02);
  135. action.createParam(PARAM_ASSIGN)
  136. .setDescription("To assign the list of issues to a specific user (login), or un-assign all the issues")
  137. .setExampleValue("john.smith")
  138. .setDeprecatedKey("assign.assignee", "6.2");
  139. action.createParam(PARAM_SET_SEVERITY)
  140. .setDescription("To change the severity of the list of issues")
  141. .setExampleValue(BLOCKER)
  142. .setPossibleValues(Severity.ALL)
  143. .setDeprecatedKey("set_severity.severity", "6.2");
  144. action.createParam(PARAM_SET_TYPE)
  145. .setDescription("To change the type of the list of issues")
  146. .setExampleValue(BUG)
  147. .setPossibleValues(RuleType.names())
  148. .setSince("5.5")
  149. .setDeprecatedKey("set_type.type", "6.2");
  150. action.createParam(PARAM_PLAN)
  151. .setDescription("In 5.5, action plans are dropped. Has no effect. To plan the list of issues to a specific action plan (key), or unlink all the issues from an action plan")
  152. .setDeprecatedSince("5.5");
  153. action.createParam(PARAM_DO_TRANSITION)
  154. .setDescription("Transition")
  155. .setExampleValue(REOPEN)
  156. .setPossibleValues(DefaultTransitions.ALL)
  157. .setDeprecatedKey("do_transition.transition", "6.2");
  158. action.createParam(PARAM_ADD_TAGS)
  159. .setDescription("Add tags")
  160. .setExampleValue("security,java8")
  161. .setDeprecatedKey("add_tags.tags", "6.2");
  162. action.createParam(PARAM_REMOVE_TAGS)
  163. .setDescription("Remove tags")
  164. .setExampleValue("security,java8")
  165. .setDeprecatedKey("remove_tags.tags", "6.2");
  166. action.createParam(PARAM_COMMENT)
  167. .setDescription("To add a comment to a list of issues")
  168. .setExampleValue("Here is my comment");
  169. action.createParam(PARAM_SEND_NOTIFICATIONS)
  170. .setSince("4.0")
  171. .setBooleanPossibleValues()
  172. .setDefaultValue("false");
  173. }
  174. @Override
  175. public void handle(Request request, Response response) throws Exception {
  176. userSession.checkLoggedIn();
  177. try (DbSession dbSession = dbClient.openSession(false)) {
  178. BulkChangeResult result = executeBulkChange(dbSession, request);
  179. writeProtobuf(toWsResponse(result), request, response);
  180. }
  181. }
  182. private BulkChangeResult executeBulkChange(DbSession dbSession, Request request) {
  183. BulkChangeData bulkChangeData = new BulkChangeData(dbSession, request);
  184. BulkChangeResult result = new BulkChangeResult(bulkChangeData.issues.size());
  185. IssueChangeContext issueChangeContext = IssueChangeContext.createUser(new Date(system2.now()), userSession.getUuid());
  186. List<DefaultIssue> items = bulkChangeData.issues.stream()
  187. .filter(bulkChange(issueChangeContext, bulkChangeData, result))
  188. .collect(MoreCollectors.toList());
  189. issueStorage.save(dbSession, items);
  190. refreshLiveMeasures(dbSession, bulkChangeData, result);
  191. Set<String> assigneeUuids = items.stream().map(DefaultIssue::assignee).filter(Objects::nonNull).collect(toSet());
  192. Map<String, UserDto> userDtoByUuid = dbClient.userDao().selectByUuids(dbSession, assigneeUuids).stream().collect(toMap(UserDto::getUuid, u -> u));
  193. String authorUuid = requireNonNull(userSession.getUuid(), "User uuid cannot be null");
  194. UserDto author = dbClient.userDao().selectByUuid(dbSession, authorUuid);
  195. checkState(author != null, "User with uuid '%s' does not exist");
  196. items.forEach(sendNotification(bulkChangeData, userDtoByUuid, author));
  197. return result;
  198. }
  199. private void refreshLiveMeasures(DbSession dbSession, BulkChangeData data, BulkChangeResult result) {
  200. if (!data.shouldRefreshMeasures()) {
  201. return;
  202. }
  203. Set<String> touchedComponentUuids = result.success.stream()
  204. .map(DefaultIssue::componentUuid)
  205. .collect(toSet());
  206. List<ComponentDto> touchedComponents = touchedComponentUuids.stream()
  207. .map(data.componentsByUuid::get)
  208. .collect(MoreCollectors.toList(touchedComponentUuids.size()));
  209. List<DefaultIssue> changedIssues = data.issues.stream().filter(result.success::contains).collect(MoreCollectors.toList());
  210. issueChangePostProcessor.process(dbSession, changedIssues, touchedComponents);
  211. }
  212. private static Predicate<DefaultIssue> bulkChange(IssueChangeContext issueChangeContext, BulkChangeData bulkChangeData, BulkChangeResult result) {
  213. return issue -> {
  214. ActionContext actionContext = new ActionContext(issue, issueChangeContext, bulkChangeData.projectsByUuid.get(issue.projectUuid()));
  215. bulkChangeData.getActionsWithoutComment().forEach(applyAction(actionContext, bulkChangeData, result));
  216. addCommentIfNeeded(actionContext, bulkChangeData);
  217. return result.success.contains(issue);
  218. };
  219. }
  220. private static Consumer<Action> applyAction(ActionContext actionContext, BulkChangeData bulkChangeData, BulkChangeResult result) {
  221. return action -> {
  222. DefaultIssue issue = actionContext.issue();
  223. try {
  224. if (action.supports(issue) && action.execute(bulkChangeData.getProperties(action.key()), actionContext)) {
  225. result.increaseSuccess(issue);
  226. }
  227. } catch (Exception e) {
  228. result.increaseFailure();
  229. LOG.error(format("An error occur when trying to apply the action : %s on issue : %s. This issue has been ignored. Error is '%s'",
  230. action.key(), issue.key(), e.getMessage()), e);
  231. }
  232. };
  233. }
  234. private static void addCommentIfNeeded(ActionContext actionContext, BulkChangeData bulkChangeData) {
  235. bulkChangeData.getCommentAction().ifPresent(action -> action.execute(bulkChangeData.getProperties(action.key()), actionContext));
  236. }
  237. private Consumer<DefaultIssue> sendNotification(BulkChangeData bulkChangeData, Map<String, UserDto> userDtoByUuid, UserDto author) {
  238. return issue -> {
  239. if (bulkChangeData.sendNotification && issue.type() != RuleType.SECURITY_HOTSPOT) {
  240. notificationService.scheduleForSending(new IssueChangeNotification()
  241. .setIssue(issue)
  242. .setAssignee(userDtoByUuid.get(issue.assignee()))
  243. .setChangeAuthor(author)
  244. .setRuleName(bulkChangeData.rulesByKey.get(issue.ruleKey()).getName())
  245. .setProject(bulkChangeData.projectsByUuid.get(issue.projectUuid()))
  246. .setComponent(bulkChangeData.componentsByUuid.get(issue.componentUuid())));
  247. }
  248. };
  249. }
  250. private static Issues.BulkChangeWsResponse toWsResponse(BulkChangeResult result) {
  251. return Issues.BulkChangeWsResponse.newBuilder()
  252. .setTotal(result.countTotal())
  253. .setSuccess(result.countSuccess())
  254. .setIgnored((long) result.countTotal() - (result.countSuccess() + result.countFailures()))
  255. .setFailures(result.countFailures())
  256. .build();
  257. }
  258. public static class ActionContext implements Action.Context {
  259. private final DefaultIssue issue;
  260. private final IssueChangeContext changeContext;
  261. private final ComponentDto project;
  262. public ActionContext(DefaultIssue issue, IssueChangeContext changeContext, ComponentDto project) {
  263. this.issue = issue;
  264. this.changeContext = changeContext;
  265. this.project = project;
  266. }
  267. @Override
  268. public DefaultIssue issue() {
  269. return issue;
  270. }
  271. @Override
  272. public IssueChangeContext issueChangeContext() {
  273. return changeContext;
  274. }
  275. @Override
  276. public ComponentDto project() {
  277. return project;
  278. }
  279. }
  280. private class BulkChangeData {
  281. private final Map<String, Map<String, Object>> propertiesByActions;
  282. private final boolean sendNotification;
  283. private final Collection<DefaultIssue> issues;
  284. private final Map<String, ComponentDto> projectsByUuid;
  285. private final Map<String, ComponentDto> componentsByUuid;
  286. private final Map<RuleKey, RuleDefinitionDto> rulesByKey;
  287. private final List<Action> availableActions;
  288. BulkChangeData(DbSession dbSession, Request request) {
  289. this.sendNotification = request.mandatoryParamAsBoolean(PARAM_SEND_NOTIFICATIONS);
  290. this.propertiesByActions = toPropertiesByActions(request);
  291. List<String> issueKeys = request.mandatoryParamAsStrings(PARAM_ISSUES);
  292. checkArgument(issueKeys.size() <= MAX_LIMIT, "Number of issues is limited to %s", MAX_LIMIT);
  293. List<IssueDto> allIssues = dbClient.issueDao().selectByKeys(dbSession, issueKeys);
  294. List<ComponentDto> allProjects = getComponents(dbSession, allIssues.stream().map(IssueDto::getProjectUuid).collect(MoreCollectors.toSet()));
  295. this.projectsByUuid = getAuthorizedProjects(allProjects).stream().collect(uniqueIndex(ComponentDto::uuid, identity()));
  296. this.issues = getAuthorizedIssues(allIssues);
  297. this.componentsByUuid = getComponents(dbSession,
  298. issues.stream().map(DefaultIssue::componentUuid).collect(MoreCollectors.toSet())).stream()
  299. .collect(uniqueIndex(ComponentDto::uuid, identity()));
  300. this.rulesByKey = dbClient.ruleDao().selectDefinitionByKeys(dbSession,
  301. issues.stream().map(DefaultIssue::ruleKey).collect(MoreCollectors.toSet())).stream()
  302. .collect(uniqueIndex(RuleDefinitionDto::getKey, identity()));
  303. this.availableActions = actions.stream()
  304. .filter(action -> propertiesByActions.containsKey(action.key()))
  305. .filter(action -> action.verify(getProperties(action.key()), issues, userSession))
  306. .collect(MoreCollectors.toList());
  307. }
  308. private List<ComponentDto> getComponents(DbSession dbSession, Collection<String> componentUuids) {
  309. return dbClient.componentDao().selectByUuids(dbSession, componentUuids);
  310. }
  311. private List<ComponentDto> getAuthorizedProjects(List<ComponentDto> projectDtos) {
  312. return userSession.keepAuthorizedComponents(UserRole.USER, projectDtos);
  313. }
  314. private List<DefaultIssue> getAuthorizedIssues(List<IssueDto> allIssues) {
  315. Set<String> projectUuids = projectsByUuid.values().stream().map(ComponentDto::uuid).collect(MoreCollectors.toSet());
  316. return allIssues.stream()
  317. .filter(issue -> projectUuids.contains(issue.getProjectUuid()))
  318. .map(IssueDto::toDefaultIssue)
  319. .collect(MoreCollectors.toList());
  320. }
  321. Map<String, Object> getProperties(String actionKey) {
  322. return propertiesByActions.get(actionKey);
  323. }
  324. List<Action> getActionsWithoutComment() {
  325. return availableActions.stream().filter(action -> !action.key().equals(COMMENT_KEY)).collect(MoreCollectors.toList());
  326. }
  327. Optional<Action> getCommentAction() {
  328. return availableActions.stream().filter(action -> action.key().equals(COMMENT_KEY)).findFirst();
  329. }
  330. private Map<String, Map<String, Object>> toPropertiesByActions(Request request) {
  331. Map<String, Map<String, Object>> properties = new HashMap<>();
  332. request.getParam(PARAM_ASSIGN, value -> properties.put(AssignAction.ASSIGN_KEY, new HashMap<>(of(ASSIGNEE_PARAMETER, value))));
  333. request.getParam(PARAM_SET_SEVERITY, value -> properties.put(SET_SEVERITY_KEY, new HashMap<>(of(SEVERITY_PARAMETER, value))));
  334. request.getParam(PARAM_SET_TYPE, value -> properties.put(SET_TYPE_KEY, new HashMap<>(of(TYPE_PARAMETER, value))));
  335. request.getParam(PARAM_DO_TRANSITION, value -> properties.put(DO_TRANSITION_KEY, new HashMap<>(of(TRANSITION_PARAMETER, value))));
  336. request.getParam(PARAM_ADD_TAGS, value -> properties.put(AddTagsAction.KEY, new HashMap<>(of(TAGS_PARAMETER, value))));
  337. request.getParam(PARAM_REMOVE_TAGS, value -> properties.put(RemoveTagsAction.KEY, new HashMap<>(of(TAGS_PARAMETER, value))));
  338. request.getParam(PARAM_COMMENT, value -> properties.put(COMMENT_KEY, new HashMap<>(of(COMMENT_PROPERTY, value))));
  339. checkAtLeastOneActionIsDefined(properties.keySet());
  340. return properties;
  341. }
  342. private void checkAtLeastOneActionIsDefined(Set<String> actions) {
  343. long actionsDefined = actions.stream().filter(action -> !action.equals(COMMENT_KEY)).count();
  344. checkArgument(actionsDefined > 0, "At least one action must be provided");
  345. }
  346. private boolean shouldRefreshMeasures() {
  347. return availableActions.stream().anyMatch(Action::shouldRefreshMeasures);
  348. }
  349. }
  350. private static class BulkChangeResult {
  351. private final int total;
  352. private final Set<DefaultIssue> success = new HashSet<>();
  353. private int failures = 0;
  354. BulkChangeResult(int total) {
  355. this.total = total;
  356. }
  357. void increaseSuccess(DefaultIssue issue) {
  358. this.success.add(issue);
  359. }
  360. void increaseFailure() {
  361. this.failures++;
  362. }
  363. int countTotal() {
  364. return total;
  365. }
  366. int countSuccess() {
  367. return success.size();
  368. }
  369. int countFailures() {
  370. return failures;
  371. }
  372. }
  373. }