]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20665 Add exact rule changes (added, deactivated, modified) in Quality Profile...
authorDimitris Kavvathas <dimitris.kavvathas@sonarsource.com>
Thu, 12 Oct 2023 08:07:37 +0000 (10:07 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 18 Oct 2023 20:03:04 +0000 (20:03 +0000)
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolver.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGenerator.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/QualityProfileEventsStep.java
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolverTest.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGeneratorTest.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/QualityProfileEventsStepTest.java

index c39754d47f8c8f07b40309930335aa209ff8fa06..78594ce7a5d4cfc194a5093de7960f4ba1f44ac8 100644 (file)
@@ -123,6 +123,7 @@ import org.sonar.ce.task.projectanalysis.qualitymodel.ReliabilityAndSecurityRati
 import org.sonar.ce.task.projectanalysis.qualitymodel.SecurityReviewMeasuresVisitor;
 import org.sonar.ce.task.projectanalysis.qualityprofile.ActiveRulesHolderImpl;
 import org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRepositoryImpl;
+import org.sonar.ce.task.projectanalysis.qualityprofile.QualityProfileRuleChangeResolver;
 import org.sonar.ce.task.projectanalysis.scm.ScmInfoDbLoader;
 import org.sonar.ce.task.projectanalysis.scm.ScmInfoRepositoryImpl;
 import org.sonar.ce.task.projectanalysis.source.DbLineHashVersion;
@@ -251,6 +252,7 @@ public final class ProjectAnalysisTaskContainerPopulator implements ContainerPop
       IssueFilter.class,
 
       FlowGenerator.class,
+      QualityProfileRuleChangeResolver.class,
       // push events
       PushEventFactory.class,
 
diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolver.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolver.java
new file mode 100644 (file)
index 0000000..32aadb0
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.ce.task.projectanalysis.qualityprofile;
+
+import java.util.AbstractMap;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.jetbrains.annotations.NotNull;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.qualityprofile.QProfileChangeDto;
+import org.sonar.db.qualityprofile.QProfileChangeQuery;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
+import org.sonar.server.qualityprofile.QualityProfile;
+
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.ACTIVATED;
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.DEACTIVATED;
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.UPDATED;
+
+public class QualityProfileRuleChangeResolver {
+  private final DbClient dbClient;
+
+  public QualityProfileRuleChangeResolver(DbClient dbClient) {
+    this.dbClient = dbClient;
+  }
+
+  /**
+   * Returns a text description of the changes made to a quality profile.
+   * The text is generated by looking at the last change for each rule and determining the final status of the rule.
+   * For old taxonomy, we need to access the QProfileChangeDto.data field to determine the ruleUuid.
+   * For CCT, we can use the QProfileChangeDto.ruleChange field to determine the ruleUuid.
+   *
+   * @param profile the quality profile to generate the text for
+   * @return a text description of the changes made to the profile
+   * @throws IllegalStateException if no changes are found for the profile
+   */
+  public Map<ActiveRuleChange.Type, Long> mapChangeToNumberOfRules(QualityProfile profile, String componentUuid) {
+    // get profile changes
+    List<QProfileChangeDto> profileChanges = getProfileChanges(profile, componentUuid);
+
+    Map<String, List<QProfileChangeDto>> updatedRulesGrouped = profileChanges.stream()
+      .filter(QualityProfileRuleChangeResolver::hasRuleUuid)
+      .collect(Collectors.groupingBy(p -> p.getRuleChange() != null ? p.getRuleChange().getRuleUuid() : p.getDataAsMap().get("ruleUuid")));
+
+    Map<String, ActiveRuleChange.Type> rulesMappedToFinalChange = getRulesMappedToFinalChange(updatedRulesGrouped);
+    return getChangeMappedToNumberOfRules(rulesMappedToFinalChange);
+  }
+
+  @NotNull
+  private static Map<ActiveRuleChange.Type, Long> getChangeMappedToNumberOfRules(Map<String, ActiveRuleChange.Type> rulesMappedToFinalChange) {
+    return rulesMappedToFinalChange.values().stream()
+      .collect(Collectors.groupingBy(
+        actionType -> actionType,
+        Collectors.counting()
+      ));
+  }
+
+  private static boolean hasRuleUuid(QProfileChangeDto change) {
+    return (change.getRuleChange() != null && change.getRuleChange().getRuleUuid() != null) ||
+      (!change.getDataAsMap().isEmpty() && change.getDataAsMap().containsKey("ruleUuid"));
+  }
+
+  /**
+   * Returns a map of ruleUuid to the final status of the rule.
+   * If the rule final status is the same as the initial status, the value will be empty.
+   *
+   * @param updatedRulesGrouped a map of ruleUuid to a list of changes for that rule
+   * @return a map of ruleUuid to the final status of the rule. If the rule final status is the same as the initial status, the value will be empty.
+   */
+  private static Map<String, ActiveRuleChange.Type> getRulesMappedToFinalChange(Map<String, List<QProfileChangeDto>> updatedRulesGrouped) {
+    return updatedRulesGrouped.entrySet().stream()
+      .map(entry -> {
+        String key = entry.getKey();
+        List<QProfileChangeDto> ruleChanges = entry.getValue();
+
+        // get last change
+        QProfileChangeDto lastChange = ruleChanges.stream().max(Comparator.comparing(QProfileChangeDto::getCreatedAt)).orElseThrow();
+        Optional<ActiveRuleChange.Type> value;
+
+        if (UPDATED.name().equals(lastChange.getChangeType())) {
+          value = Optional.of(UPDATED);
+        } else {
+          // for ACTIVATED/DEACTIVATED we need to count the number of times the rule was toggled
+          long activationToggles = ruleChanges.stream()
+            .filter(rule -> List.of(ACTIVATED.name(), DEACTIVATED.name()).contains(rule.getChangeType()))
+            .count();
+          // If the count is even, skip all rules in this group as the status is unchanged
+          // If the count is odd we only care about the last status update
+          value = activationToggles % 2 == 0 ? Optional.empty() : Optional.of(ActiveRuleChange.Type.valueOf(lastChange.getChangeType()));
+        }
+
+        return new AbstractMap.SimpleEntry<>(key, value);
+      })
+      .filter(entry -> entry.getValue().isPresent())
+      .collect(Collectors.toMap(
+        Map.Entry::getKey,
+        entry -> entry.getValue().get()
+      ));
+  }
+
+  private List<QProfileChangeDto> getProfileChanges(QualityProfile profile, String componentUuid) {
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      QProfileChangeQuery query = new QProfileChangeQuery(profile.getQpKey());
+      query.setFromIncluded(getLastAnalysisDate(componentUuid, dbSession));
+      List<QProfileChangeDto> profileChanges = dbClient.qProfileChangeDao().selectByQuery(dbSession, query);
+      if (profileChanges.isEmpty()) {
+        throw new IllegalStateException("No profile changes found for " + profile.getQpName());
+      }
+      return profileChanges;
+    }
+  }
+
+  @NotNull
+  private Long getLastAnalysisDate(String componentUuid, DbSession dbSession) {
+    return dbClient.snapshotDao().selectLastAnalysisByComponentUuid(dbSession, componentUuid)
+      .orElseThrow(() -> new IllegalStateException("No snapshot found for " + componentUuid)).getAnalysisDate();
+  }
+
+}
diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGenerator.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGenerator.java
new file mode 100644 (file)
index 0000000..b40c1cc
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.ce.task.projectanalysis.qualityprofile;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
+
+/**
+ * Builder for generating a text description of the changes made to a quality profile.
+ */
+public final class QualityProfileTextGenerator {
+
+  private static final Map<ActiveRuleChange.Type, String> CHANGE_TO_TEXT_MAP = Map.ofEntries(
+    Map.entry(ActiveRuleChange.Type.ACTIVATED, " new rule"),
+    Map.entry(ActiveRuleChange.Type.DEACTIVATED, " deactivated rule"),
+    Map.entry(ActiveRuleChange.Type.UPDATED, " modified rule")
+  );
+
+  private QualityProfileTextGenerator() {
+    // only static methods
+  }
+
+  /**
+   * Returns a text description of the changes made to a quality profile. Oxford comma is not used.
+   * The order of the changes is based on the order of the enum name (activated, deactivated, updated) to keep consistency.
+   * 0 values are filtered out.
+   *
+   * @param changesMappedToNumberOfRules the changes mapped to the number of rules
+   * @return a text description of the changes made to the profile
+   */
+  public static String generateRuleChangeText(Map<ActiveRuleChange.Type, Long> changesMappedToNumberOfRules) {
+
+    return changesMappedToNumberOfRules.entrySet().stream()
+      .sorted(Map.Entry.comparingByKey(Comparator.comparing(Enum::name)))
+      .filter(entry -> entry.getValue() > 0)
+      .map(entry -> generateRuleText(entry.getValue(), CHANGE_TO_TEXT_MAP.get(entry.getKey())))
+      .collect(Collectors.collectingAndThen(Collectors.toList(), joiningLastDelimiter(", ", " and ")));
+  }
+
+  private static String generateRuleText(Long ruleNumber, String ruleText) {
+    return ruleNumber + ruleText + (ruleNumber > 1 ? "s" : "");
+  }
+
+  private static Function<List<String>, String> joiningLastDelimiter(String delimiter, String lastDelimiter) {
+    return list -> {
+      int last = list.size() - 1;
+      if (last < 1) return String.join(delimiter, list);
+      return String.join(lastDelimiter,
+        String.join(delimiter, list.subList(0, last)),
+        list.get(last));
+    };
+  }
+}
index d80ab7addc55d19436013a486c56fabaa46485ec..22a849de8315f44c6a29251a145cb1d865d23f9c 100644 (file)
@@ -26,6 +26,8 @@ import java.util.Map;
 import java.util.Optional;
 import javax.annotation.Nullable;
 import org.apache.commons.lang.time.DateUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.sonar.api.measures.CoreMetrics;
 import org.sonar.api.resources.Language;
 import org.sonar.api.utils.KeyValueFormat;
@@ -38,8 +40,11 @@ import org.sonar.ce.task.projectanalysis.measure.Measure;
 import org.sonar.ce.task.projectanalysis.measure.MeasureRepository;
 import org.sonar.ce.task.projectanalysis.metric.MetricRepository;
 import org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRepository;
+import org.sonar.ce.task.projectanalysis.qualityprofile.QualityProfileRuleChangeResolver;
+import org.sonar.ce.task.projectanalysis.qualityprofile.QualityProfileTextGenerator;
 import org.sonar.ce.task.step.ComputationStep;
 import org.sonar.core.util.UtcDateUtils;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
 import org.sonar.server.qualityprofile.QPMeasureData;
 import org.sonar.server.qualityprofile.QualityProfile;
 
@@ -52,22 +57,25 @@ import static org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRep
  * As it depends upon {@link CoreMetrics#QUALITY_PROFILES_KEY}, it must be executed after {@link ComputeQProfileMeasureStep}
  */
 public class QualityProfileEventsStep implements ComputationStep {
+  private static final Logger LOG = LoggerFactory.getLogger(QualityProfileEventsStep.class);
   private final TreeRootHolder treeRootHolder;
   private final MetricRepository metricRepository;
   private final MeasureRepository measureRepository;
   private final EventRepository eventRepository;
   private final LanguageRepository languageRepository;
   private final QProfileStatusRepository qProfileStatusRepository;
+  private final QualityProfileRuleChangeResolver qualityProfileRuleChangeTextResolver;
 
   public QualityProfileEventsStep(TreeRootHolder treeRootHolder,
     MetricRepository metricRepository, MeasureRepository measureRepository, LanguageRepository languageRepository,
-    EventRepository eventRepository, QProfileStatusRepository qProfileStatusRepository) {
+    EventRepository eventRepository, QProfileStatusRepository qProfileStatusRepository, QualityProfileRuleChangeResolver qualityProfileRuleChangeTextResolver) {
     this.treeRootHolder = treeRootHolder;
     this.metricRepository = metricRepository;
     this.measureRepository = measureRepository;
     this.eventRepository = eventRepository;
     this.languageRepository = languageRepository;
     this.qProfileStatusRepository = qProfileStatusRepository;
+    this.qualityProfileRuleChangeTextResolver = qualityProfileRuleChangeTextResolver;
   }
 
   @Override
@@ -77,21 +85,21 @@ public class QualityProfileEventsStep implements ComputationStep {
 
   private void executeForBranch(Component branchComponent) {
     Optional<Measure> baseMeasure = measureRepository.getBaseMeasure(branchComponent, metricRepository.getByKey(CoreMetrics.QUALITY_PROFILES_KEY));
-    if (!baseMeasure.isPresent()) {
+    if (baseMeasure.isEmpty()) {
       // first analysis -> do not generate events
       return;
     }
 
     // Load profiles used in current analysis for which at least one file of the corresponding language exists
     Optional<Measure> rawMeasure = measureRepository.getRawMeasure(branchComponent, metricRepository.getByKey(CoreMetrics.QUALITY_PROFILES_KEY));
-    if (!rawMeasure.isPresent()) {
+    if (rawMeasure.isEmpty()) {
       // No qualify profile computed on the project
       return;
     }
     Map<String, QualityProfile> rawProfiles = QPMeasureData.fromJson(rawMeasure.get().getStringValue()).getProfilesByKey();
 
     Map<String, QualityProfile> baseProfiles = parseJsonData(baseMeasure.get());
-    detectNewOrUpdatedProfiles(baseProfiles, rawProfiles);
+    detectNewOrUpdatedProfiles(baseProfiles, rawProfiles, branchComponent.getUuid());
     detectNoMoreUsedProfiles(baseProfiles);
   }
 
@@ -111,26 +119,39 @@ public class QualityProfileEventsStep implements ComputationStep {
     }
   }
 
-  private void detectNewOrUpdatedProfiles(Map<String, QualityProfile> baseProfiles, Map<String, QualityProfile> rawProfiles) {
+  private void detectNewOrUpdatedProfiles(Map<String, QualityProfile> baseProfiles, Map<String, QualityProfile> rawProfiles, String componentUuid) {
     for (QualityProfile profile : rawProfiles.values()) {
       qProfileStatusRepository.get(profile.getQpKey()).ifPresent(status -> {
         if (status.equals(ADDED)) {
           markAsAdded(profile);
         } else if (status.equals(UPDATED)) {
-          markAsChanged(baseProfiles.get(profile.getQpKey()), profile);
+          markAsChanged(baseProfiles.get(profile.getQpKey()), profile, componentUuid);
         }
       });
     }
   }
 
-  private void markAsChanged(QualityProfile baseProfile, QualityProfile profile) {
-    Date from = baseProfile.getRulesUpdatedAt();
+  private void markAsChanged(QualityProfile baseProfile, QualityProfile profile, String componentUuid) {
+    try {
+      Map<ActiveRuleChange.Type, Long> changesMappedToNumberOfRules = qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(baseProfile, componentUuid);
 
-    String data = KeyValueFormat.format(ImmutableSortedMap.of(
-      "key", profile.getQpKey(),
-      "from", UtcDateUtils.formatDateTime(fixDate(from)),
-      "to", UtcDateUtils.formatDateTime(fixDate(profile.getRulesUpdatedAt()))));
-    eventRepository.add(createQProfileEvent(profile, "Changes in %s", data));
+      if (changesMappedToNumberOfRules.isEmpty()) {
+        LOG.debug("No changes found for Quality Profile {}. Quality Profile event skipped.", profile.getQpKey());
+        return;
+      }
+
+      String data = KeyValueFormat.format(ImmutableSortedMap.of(
+        "key", profile.getQpKey(),
+        "from", UtcDateUtils.formatDateTime(fixDate(baseProfile.getRulesUpdatedAt())),
+        "to", UtcDateUtils.formatDateTime(fixDate(profile.getRulesUpdatedAt())),
+        "name", profile.getQpName(),
+        "languageKey", profile.getLanguageKey()));
+      String ruleChangeText = QualityProfileTextGenerator.generateRuleChangeText(changesMappedToNumberOfRules);
+
+      eventRepository.add(createQProfileEvent(profile, "%s updated with " + ruleChangeText, data, ruleChangeText));
+    } catch (Exception e) {
+      LOG.error("Failed to generate 'change' event for Quality Profile " + profile.getQpKey(), e);
+    }
   }
 
   private void markAsRemoved(QualityProfile profile) {
@@ -149,10 +170,14 @@ public class QualityProfileEventsStep implements ComputationStep {
     return Event.createProfile(String.format(namePattern, profileLabel(profile)), data, null);
   }
 
+  private Event createQProfileEvent(QualityProfile profile, String namePattern, @Nullable String data, @Nullable String description) {
+    return Event.createProfile(String.format(namePattern, profileLabel(profile)), data, description);
+  }
+
   private String profileLabel(QualityProfile profile) {
     Optional<Language> language = languageRepository.find(profile.getLanguageKey());
     String languageName = language.isPresent() ? language.get().getName() : profile.getLanguageKey();
-    return String.format("'%s' (%s)", profile.getQpName(), languageName);
+    return String.format("\"%s\" (%s)", profile.getQpName(), languageName);
   }
 
   /**
diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolverTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileRuleChangeResolverTest.java
new file mode 100644 (file)
index 0000000..64ac60c
--- /dev/null
@@ -0,0 +1,296 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.ce.task.projectanalysis.qualityprofile;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Suite;
+import org.sonar.api.rules.CleanCodeAttribute;
+import org.sonar.api.utils.KeyValueFormat;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.component.SnapshotDao;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.qualityprofile.QProfileChangeDao;
+import org.sonar.db.qualityprofile.QProfileChangeDto;
+import org.sonar.db.qualityprofile.QProfileChangeQuery;
+import org.sonar.db.rule.RuleChangeDto;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
+import org.sonar.server.qualityprofile.QualityProfile;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.ACTIVATED;
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.DEACTIVATED;
+import static org.sonar.server.qualityprofile.ActiveRuleChange.Type.UPDATED;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  QualityProfileRuleChangeResolverTest.TextResolutionTest.class,
+  QualityProfileRuleChangeResolverTest.ExceptionTest.class
+})
+public class QualityProfileRuleChangeResolverTest {
+  private final static String COMPONENT_UUID = "123";
+
+  @RunWith(Parameterized.class)
+  public static class TextResolutionTest {
+    private final DbClient dbClient = mock(DbClient.class);
+    private final DbSession dbSession = mock(DbSession.class);
+    private final QProfileChangeDao qProfileChangeDao = mock(QProfileChangeDao.class);
+    private final SnapshotDao snapshotDao = mock(SnapshotDao.class);
+    private final QualityProfile qualityProfile = mock(QualityProfile.class);
+
+    private final QualityProfileRuleChangeResolver underTest = new QualityProfileRuleChangeResolver(dbClient);
+
+    private final List<QProfileChangeDto> changes;
+    private final Map<ActiveRuleChange.Type, Long> expectedMap;
+
+
+    public TextResolutionTest(List<QProfileChangeDto> changes, Map<ActiveRuleChange.Type, Long> expectedMap) {
+      this.changes = changes;
+      this.expectedMap = expectedMap;
+    }
+
+    @Parameterized.Parameters
+    public static Collection<Object[]> data() {
+      return Arrays.asList(new Object[][]{
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createChange(DEACTIVATED, "ruleUuid2", 124L),
+            createChange(UPDATED, "ruleUuid3", 125L)
+          ),
+          Map.ofEntries(
+            Map.entry(ACTIVATED, 1L),
+            Map.entry(DEACTIVATED, 1L),
+            Map.entry(UPDATED, 1L)
+          )
+        },
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createChange(DEACTIVATED, "ruleUuid1", 124L), // should cancel previous change
+            createChange(UPDATED, "ruleUuid2", 125L)
+          ),
+          Map.ofEntries(
+            Map.entry(UPDATED, 1L)
+          )
+        },
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createChange(DEACTIVATED, "ruleUuid1", 124L), // should cancel previous change
+            createChange(ACTIVATED, "ruleUuid1", 125L),
+            createChange(UPDATED, "ruleUuid2", 126L)
+          ),
+          Map.ofEntries(
+            Map.entry(ACTIVATED, 1L),
+            Map.entry(UPDATED, 1L)
+          )
+        },
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createCCTUpdate("ruleUuid1", 130L), // should overwrite previous change
+            createChange(DEACTIVATED, "ruleUuid2", 124L),
+            createChange(DEACTIVATED, "ruleUuid3", 125L),
+            createChange(UPDATED, "ruleUuid4", 126L)
+          ),
+          Map.ofEntries(
+            Map.entry(DEACTIVATED, 2L),
+            Map.entry(UPDATED, 2L)
+          )
+        },
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createChange(UPDATED, "ruleUuid1", 130L),
+            createChange(DEACTIVATED, "ruleUuid1", 131L), // should overwrite update and cancel out the activation resulting to no change
+            createCCTUpdate("ruleUuid2", 126L)
+          ),
+          Map.ofEntries(
+            Map.entry(UPDATED, 1L)
+          )
+        },
+        {
+          List.of(
+            createChange(DEACTIVATED, "ruleUuid1", 123L),
+            createChange(UPDATED, "ruleUuid1", 130L),
+            createChange(UPDATED, "ruleUuid2", 126L)
+          ),
+          Map.ofEntries(
+            Map.entry(UPDATED, 2L)
+          )
+        },
+        {
+          // single CCT change
+          List.of(
+            createCCTUpdate("ruleUuid1", 123L)
+          ),
+          Map.ofEntries(
+            Map.entry(UPDATED, 1L)
+          )
+        },
+        {
+          // multiple CCT changes
+          List.of(
+            createCCTUpdate("ruleUuid1", 123L),
+            createCCTUpdate("ruleUuid2", 124L)
+          ),
+          Map.ofEntries(
+            Map.entry(UPDATED, 2L)
+          )
+        },
+        {
+          // mixed CCT and old taxonomy changes
+          List.of(
+            createCCTUpdate("ruleUuid1", 123L),
+            createChange(ACTIVATED, "ruleUuid2", 124L),
+            createCCTUpdate("ruleUuid3", 125L),
+            createChange(DEACTIVATED, "ruleUuid4", 126L),
+            createChange(UPDATED, "ruleUuid3", 127L)
+          ),
+          Map.ofEntries(
+            Map.entry(ACTIVATED, 1L),
+            Map.entry(DEACTIVATED, 1L),
+            Map.entry(UPDATED, 2L)
+          )
+        },
+        {
+          List.of(
+            createChange(ACTIVATED, "ruleUuid1", 123L),
+            createChange(DEACTIVATED, "ruleUuid1", 124L) // should cancel previous change
+          ),
+          Map.ofEntries()
+        }
+      });
+    }
+
+    @Before
+    public void setUp() {
+      when(dbClient.openSession(false)).thenReturn(dbSession);
+      doReturn(qProfileChangeDao).when(dbClient).qProfileChangeDao();
+
+      SnapshotDto snapshotDto = new SnapshotDto()
+        .setAnalysisDate(123L);
+      doReturn(Optional.of(snapshotDto)).when(snapshotDao).selectLastAnalysisByComponentUuid(dbSession, COMPONENT_UUID);
+      doReturn(snapshotDao).when(dbClient).snapshotDao();
+
+      doReturn("profileUuid").when(qualityProfile).getQpKey();
+      doReturn("profileName").when(qualityProfile).getQpName();
+    }
+
+    @Test
+    public void givenQPChanges_whenResolveText_thenResolvedTextContainsAll() {
+      // given
+      doReturn(changes).when(qProfileChangeDao).selectByQuery(eq(dbSession), any(QProfileChangeQuery.class));
+
+      // when
+      Map<ActiveRuleChange.Type, Long> changeToNumberOfRules = underTest.mapChangeToNumberOfRules(qualityProfile, COMPONENT_UUID);
+
+      // then
+      assertThat(changeToNumberOfRules).isEqualTo(expectedMap);
+    }
+  }
+
+  public static class ExceptionTest {
+
+    private final DbClient dbClient = mock(DbClient.class);
+    private final DbSession dbSession = mock(DbSession.class);
+    private final QProfileChangeDao qProfileChangeDao = mock(QProfileChangeDao.class);
+    private final SnapshotDao snapshotDao = mock(SnapshotDao.class);
+
+    private final QualityProfile qualityProfile = mock(QualityProfile.class);
+
+    private final QualityProfileRuleChangeResolver underTest = new QualityProfileRuleChangeResolver(dbClient);
+
+
+    @Before
+    public void setUp() {
+      when(dbClient.openSession(false)).thenReturn(dbSession);
+      doReturn(qProfileChangeDao).when(dbClient).qProfileChangeDao();
+
+      SnapshotDto snapshotDto = new SnapshotDto()
+        .setAnalysisDate(123L);
+      doReturn(Optional.of(snapshotDto)).when(snapshotDao).selectLastAnalysisByComponentUuid(dbSession, COMPONENT_UUID);
+      doReturn(snapshotDao).when(dbClient).snapshotDao();
+
+      doReturn("profileUuid").when(qualityProfile).getQpKey();
+      doReturn("profileName").when(qualityProfile).getQpName();
+    }
+
+    @Test
+    public void givenNoQPChanges_whenResolveText_thenThrows() {
+      // given
+      doReturn(List.of()).when(qProfileChangeDao).selectByQuery(eq(dbSession), any(QProfileChangeQuery.class));
+
+      // when then
+      assertThatThrownBy(() -> underTest.mapChangeToNumberOfRules(qualityProfile, COMPONENT_UUID))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("No profile changes found for profileName");
+    }
+
+    @Test
+    public void givenNoSnapshotFound_whenResolveText_thenThrows() {
+      // given
+      doReturn(Optional.empty()).when(snapshotDao).selectLastAnalysisByComponentUuid(dbSession, COMPONENT_UUID);
+
+      // when then
+      assertThatThrownBy(() -> underTest.mapChangeToNumberOfRules(qualityProfile, COMPONENT_UUID))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("No snapshot found for 123");
+    }
+  }
+
+  private static QProfileChangeDto createChange(ActiveRuleChange.Type type, String ruleUuid, Long createdAt) {
+    return new QProfileChangeDto()
+      .setUuid("uuid")
+      .setCreatedAt(createdAt)
+      .setRulesProfileUuid("ruleProfileUuid")
+      .setChangeType(type.name())
+      .setData(KeyValueFormat.parse("ruleUuid=" + ruleUuid));
+  }
+
+  private static QProfileChangeDto createCCTUpdate(String ruleUuid, Long createdAt) {
+    RuleChangeDto ruleChangeDto = new RuleChangeDto();
+    ruleChangeDto.setOldCleanCodeAttribute(CleanCodeAttribute.CONVENTIONAL);
+    ruleChangeDto.setNewCleanCodeAttribute(CleanCodeAttribute.CLEAR);
+    ruleChangeDto.setRuleUuid(ruleUuid);
+    return new QProfileChangeDto()
+      .setUuid("uuid")
+      .setCreatedAt(createdAt)
+      .setRulesProfileUuid("ruleProfileUuid")
+      .setChangeType(UPDATED.name())
+      .setRuleChange(ruleChangeDto);
+  }
+
+}
\ No newline at end of file
diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGeneratorTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/qualityprofile/QualityProfileTextGeneratorTest.java
new file mode 100644 (file)
index 0000000..7884d8f
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.ce.task.projectanalysis.qualityprofile;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(Parameterized.class)
+public class QualityProfileTextGeneratorTest {
+
+  private final Map<ActiveRuleChange.Type, Long> changeToNumberOfRules;
+  private final String expectedText;
+
+  public QualityProfileTextGeneratorTest(Map<ActiveRuleChange.Type, Long> changeToNumberOfRules, String expectedText) {
+    this.changeToNumberOfRules = changeToNumberOfRules;
+    this.expectedText = expectedText;
+  }
+
+  @Parameterized.Parameters
+  public static Collection<Object[]> data() {
+    return Arrays.asList(new Object[][]{
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.ACTIVATED, 12L),
+          Map.entry(ActiveRuleChange.Type.DEACTIVATED, 8L),
+          Map.entry(ActiveRuleChange.Type.UPDATED, 5L)
+        ),
+        "12 new rules, 8 deactivated rules and 5 modified rules"
+      },
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.ACTIVATED, 1L),
+          Map.entry(ActiveRuleChange.Type.DEACTIVATED, 0L),
+          Map.entry(ActiveRuleChange.Type.UPDATED, 0L)
+        ),
+        "1 new rule"
+      },
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.DEACTIVATED, 5L)
+        ),
+        "5 deactivated rules"
+      },
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.UPDATED, 7L)
+        ),
+        "7 modified rules"
+      },
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.ACTIVATED, 1L),
+          Map.entry(ActiveRuleChange.Type.DEACTIVATED, 1L),
+          Map.entry(ActiveRuleChange.Type.UPDATED, 1L)
+        ),
+        "1 new rule, 1 deactivated rule and 1 modified rule"
+      },
+      {
+        Map.ofEntries(
+          Map.entry(ActiveRuleChange.Type.ACTIVATED, 1L),
+          Map.entry(ActiveRuleChange.Type.UPDATED, 3L)
+        ),
+        "1 new rule and 3 modified rules"
+      }
+    });
+  }
+
+  @Test
+  public void givenRulesChanges_whenBuild_thenTextContainsAll() {
+    // given when
+    String updateMessage = QualityProfileTextGenerator.generateRuleChangeText(changeToNumberOfRules);
+
+    // then
+    assertThat(updateMessage).isEqualTo(expectedText);
+  }
+
+}
index 3ea123e8969ba778a093f62ff8da0b900f3dae28..8811ce81cafe1dfae20af3031dda6c88a98d6f6c 100644 (file)
@@ -24,6 +24,7 @@ import java.util.Collections;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import javax.annotation.Nullable;
@@ -31,9 +32,11 @@ import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
+import org.slf4j.event.Level;
 import org.sonar.api.measures.CoreMetrics;
 import org.sonar.api.resources.AbstractLanguage;
 import org.sonar.api.resources.Language;
+import org.sonar.api.testfixtures.log.LogTester;
 import org.sonar.ce.task.projectanalysis.component.Component;
 import org.sonar.ce.task.projectanalysis.component.ReportComponent;
 import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule;
@@ -46,8 +49,10 @@ import org.sonar.ce.task.projectanalysis.metric.Metric;
 import org.sonar.ce.task.projectanalysis.metric.MetricRepository;
 import org.sonar.ce.task.projectanalysis.qualityprofile.MutableQProfileStatusRepository;
 import org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRepositoryImpl;
+import org.sonar.ce.task.projectanalysis.qualityprofile.QualityProfileRuleChangeResolver;
 import org.sonar.ce.task.step.TestComputationStepContext;
 import org.sonar.core.util.UtcDateUtils;
+import org.sonar.server.qualityprofile.ActiveRuleChange;
 import org.sonar.server.qualityprofile.QPMeasureData;
 import org.sonar.server.qualityprofile.QualityProfile;
 
@@ -67,8 +72,20 @@ import static org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRep
 import static org.sonar.ce.task.projectanalysis.qualityprofile.QProfileStatusRepository.Status.UPDATED;
 
 public class QualityProfileEventsStepTest {
+  private static final Date BEFORE_DATE = parseDateTime("2011-04-25T01:05:13+0100");
+  private static final Date AFTER_DATE = parseDateTime("2011-04-25T01:05:17+0100");
+  private static final Date BEFORE_DATE_PLUS_1_SEC = parseDateTime("2011-04-25T01:05:14+0100");
+  private static final Date AFTER_DATE_PLUS_1_SEC = parseDateTime("2011-04-25T01:05:18+0100");
+  private static final String RULE_CHANGE_TEXT = "1 new rule, 2 deactivated rules and 3 modified rules";
+  private static final Map<ActiveRuleChange.Type, Long> CHANGE_TO_NUMBER_OF_RULES_MAP = Map.ofEntries(
+    Map.entry(ActiveRuleChange.Type.ACTIVATED, 1L),
+    Map.entry(ActiveRuleChange.Type.DEACTIVATED, 2L),
+    Map.entry(ActiveRuleChange.Type.UPDATED, 3L)
+  );
   @Rule
   public TreeRootHolderRule treeRootHolder = new TreeRootHolderRule();
+  @Rule
+  public LogTester logTester = new LogTester();
 
   private static final String QP_NAME_1 = "qp_1";
   private static final String QP_NAME_2 = "qp_2";
@@ -76,16 +93,16 @@ public class QualityProfileEventsStepTest {
   private static final String LANGUAGE_KEY_2 = "language_key_2";
   private static final String LANGUAGE_KEY_3 = "languageKey3";
 
-  private MetricRepository metricRepository = mock(MetricRepository.class);
-  private MeasureRepository measureRepository = mock(MeasureRepository.class);
-  private LanguageRepository languageRepository = mock(LanguageRepository.class);
-  private EventRepository eventRepository = mock(EventRepository.class);
-  private ArgumentCaptor<Event> eventArgumentCaptor = ArgumentCaptor.forClass(Event.class);
-  private MutableQProfileStatusRepository qProfileStatusRepository = new QProfileStatusRepositoryImpl();
-
-  private Metric qualityProfileMetric = mock(Metric.class);
+  private final MetricRepository metricRepository = mock(MetricRepository.class);
+  private final MeasureRepository measureRepository = mock(MeasureRepository.class);
+  private final LanguageRepository languageRepository = mock(LanguageRepository.class);
+  private final EventRepository eventRepository = mock(EventRepository.class);
+  private final ArgumentCaptor<Event> eventArgumentCaptor = ArgumentCaptor.forClass(Event.class);
+  private final MutableQProfileStatusRepository qProfileStatusRepository = new QProfileStatusRepositoryImpl();
 
-  private QualityProfileEventsStep underTest = new QualityProfileEventsStep(treeRootHolder, metricRepository, measureRepository, languageRepository, eventRepository, qProfileStatusRepository);
+  private final Metric qualityProfileMetric = mock(Metric.class);
+  private final QualityProfileRuleChangeResolver qualityProfileRuleChangeTextResolver = mock(QualityProfileRuleChangeResolver.class);
+  private final QualityProfileEventsStep underTest = new QualityProfileEventsStep(treeRootHolder, metricRepository, measureRepository, languageRepository, eventRepository, qProfileStatusRepository, qualityProfileRuleChangeTextResolver);
 
   @Before
   public void setUp() {
@@ -114,7 +131,7 @@ public class QualityProfileEventsStepTest {
 
   @Test
   public void no_event_if_no_base_and_quality_profile_measure_is_empty() {
-    mockMeasures(treeRootHolder.getRoot(), null, null);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), null, null);
 
     underTest.execute(new TestComputationStepContext());
 
@@ -127,13 +144,13 @@ public class QualityProfileEventsStepTest {
     qProfileStatusRepository.register(qp.getQpKey(), ADDED);
 
     Language language = mockLanguageInRepository(LANGUAGE_KEY_1);
-    mockMeasures(treeRootHolder.getRoot(), null, arrayOf(qp));
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), null, arrayOf(qp));
 
     underTest.execute(new TestComputationStepContext());
 
     verify(eventRepository).add(eventArgumentCaptor.capture());
     verifyNoMoreInteractions(eventRepository);
-    verifyEvent(eventArgumentCaptor.getValue(), "Use '" + qp.getQpName() + "' (" + language.getName() + ")", null);
+    verifyEvent(eventArgumentCaptor.getValue(), "Use \"" + qp.getQpName() + "\" (" + language.getName() + ")", null, null);
   }
 
   @Test
@@ -142,13 +159,13 @@ public class QualityProfileEventsStepTest {
     qProfileStatusRepository.register(qp.getQpKey(), ADDED);
 
     mockLanguageNotInRepository(LANGUAGE_KEY_1);
-    mockMeasures(treeRootHolder.getRoot(), null, arrayOf(qp));
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), null, arrayOf(qp));
 
     underTest.execute(new TestComputationStepContext());
 
     verify(eventRepository).add(eventArgumentCaptor.capture());
     verifyNoMoreInteractions(eventRepository);
-    verifyEvent(eventArgumentCaptor.getValue(), "Use '" + qp.getQpName() + "' (" + qp.getLanguageKey() + ")", null);
+    verifyEvent(eventArgumentCaptor.getValue(), "Use \"" + qp.getQpName() + "\" (" + qp.getLanguageKey() + ")", null, null);
   }
 
   @Test
@@ -156,35 +173,35 @@ public class QualityProfileEventsStepTest {
     QualityProfile qp = qp(QP_NAME_1, LANGUAGE_KEY_1, new Date());
     qProfileStatusRepository.register(qp.getQpKey(), REMOVED);
 
-    mockMeasures(treeRootHolder.getRoot(), arrayOf(qp), null);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(qp), null);
     Language language = mockLanguageInRepository(LANGUAGE_KEY_1);
 
     underTest.execute(new TestComputationStepContext());
 
     verify(eventRepository).add(eventArgumentCaptor.capture());
     verifyNoMoreInteractions(eventRepository);
-    verifyEvent(eventArgumentCaptor.getValue(), "Stop using '" + qp.getQpName() + "' (" + language.getName() + ")", null);
+    verifyEvent(eventArgumentCaptor.getValue(), "Stop using \"" + qp.getQpName() + "\" (" + language.getName() + ")", null, null);
   }
 
   @Test
   public void no_more_used_event_uses_language_key_in_message_if_language_not_found() {
     QualityProfile qp = qp(QP_NAME_1, LANGUAGE_KEY_1, new Date());
     qProfileStatusRepository.register(qp.getQpKey(), REMOVED);
-    mockMeasures(treeRootHolder.getRoot(), arrayOf(qp), null);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(qp), null);
     mockLanguageNotInRepository(LANGUAGE_KEY_1);
 
     underTest.execute(new TestComputationStepContext());
 
     verify(eventRepository).add(eventArgumentCaptor.capture());
     verifyNoMoreInteractions(eventRepository);
-    verifyEvent(eventArgumentCaptor.getValue(), "Stop using '" + qp.getQpName() + "' (" + qp.getLanguageKey() + ")", null);
+    verifyEvent(eventArgumentCaptor.getValue(), "Stop using \"" + qp.getQpName() + "\" (" + qp.getLanguageKey() + ")", null, null);
   }
 
   @Test
   public void no_event_if_qp_is_unchanged() {
     QualityProfile qp = qp(QP_NAME_1, LANGUAGE_KEY_1, new Date());
     qProfileStatusRepository.register(qp.getQpKey(), UNCHANGED);
-    mockMeasures(treeRootHolder.getRoot(), arrayOf(qp), arrayOf(qp));
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(qp), arrayOf(qp));
 
     underTest.execute(new TestComputationStepContext());
 
@@ -193,20 +210,97 @@ public class QualityProfileEventsStepTest {
 
   @Test
   public void changed_event_if_qp_has_been_updated() {
-    QualityProfile qp1 = qp(QP_NAME_1, LANGUAGE_KEY_1, parseDateTime("2011-04-25T01:05:13+0100"));
-    QualityProfile qp2 = qp(QP_NAME_1, LANGUAGE_KEY_1, parseDateTime("2011-04-25T01:05:17+0100"));
+    QualityProfile qp1 = qp(QP_NAME_1, LANGUAGE_KEY_1, BEFORE_DATE);
+    QualityProfile qp2 = qp(QP_NAME_1, LANGUAGE_KEY_1, AFTER_DATE);
     qProfileStatusRepository.register(qp2.getQpKey(), UPDATED);
-    mockMeasures(treeRootHolder.getRoot(), arrayOf(qp1), arrayOf(qp2));
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(qp1), arrayOf(qp2));
     Language language = mockLanguageInRepository(LANGUAGE_KEY_1);
 
+    when(qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(qp2, treeRootHolder.getRoot().getUuid())).thenReturn(CHANGE_TO_NUMBER_OF_RULES_MAP);
+
     underTest.execute(new TestComputationStepContext());
 
     verify(eventRepository).add(eventArgumentCaptor.capture());
     verifyNoMoreInteractions(eventRepository);
     verifyEvent(eventArgumentCaptor.getValue(),
-      "Changes in '" + qp2.getQpName() + "' (" + language.getName() + ")",
-      "from=" + UtcDateUtils.formatDateTime(parseDateTime("2011-04-25T01:05:14+0100")) + ";key=" + qp1.getQpKey() + ";to="
-        + UtcDateUtils.formatDateTime(parseDateTime("2011-04-25T01:05:18+0100")));
+      "\"" + qp2.getQpName() + "\" (" + language.getName() + ") updated with " + RULE_CHANGE_TEXT,
+      "from=" + UtcDateUtils.formatDateTime(BEFORE_DATE_PLUS_1_SEC) +
+        ";key=" + qp1.getQpKey() +
+        ";languageKey=" + qp2.getLanguageKey()+
+        ";name=" + qp2.getQpName() +
+        ";to=" + UtcDateUtils.formatDateTime(AFTER_DATE_PLUS_1_SEC),
+      RULE_CHANGE_TEXT);
+  }
+
+  @Test
+  public void givenRulesWhereAddedModifiedOrRemoved_whenEventStep_thenQPChangeEventIsAddedWithDetails() {
+    QualityProfile qp1 = qp(QP_NAME_1, LANGUAGE_KEY_1, BEFORE_DATE);
+    QualityProfile qp2 = qp(QP_NAME_1, LANGUAGE_KEY_1, AFTER_DATE);
+
+    // mock updated profile
+    qProfileStatusRepository.register(qp2.getQpKey(), UPDATED);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(qp1), arrayOf(qp2));
+    Language language = mockLanguageInRepository(LANGUAGE_KEY_1);
+
+    // mock rule changes
+    when(qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(qp2, treeRootHolder.getRoot().getUuid())).thenReturn(CHANGE_TO_NUMBER_OF_RULES_MAP);
+
+    underTest.execute(new TestComputationStepContext());
+
+    verify(eventRepository).add(eventArgumentCaptor.capture());
+    verifyNoMoreInteractions(eventRepository);
+    verifyEvent(eventArgumentCaptor.getValue(),
+      "\"" + qp2.getQpName() + "\" (" + language.getName() + ") updated with " + RULE_CHANGE_TEXT,
+      "from=" + UtcDateUtils.formatDateTime(BEFORE_DATE_PLUS_1_SEC) +
+        ";key=" + qp1.getQpKey() +
+        ";languageKey=" + qp2.getLanguageKey()+
+        ";name=" + qp2.getQpName() +
+        ";to=" + UtcDateUtils.formatDateTime(AFTER_DATE_PLUS_1_SEC),
+      RULE_CHANGE_TEXT);
+  }
+
+  @Test
+  public void givenRuleTextResolverException_whenEventStep_thenLogAndContinue() {
+    // given
+    logTester.setLevel(Level.ERROR);
+    QualityProfile existingQP = qp(QP_NAME_1, LANGUAGE_KEY_1, BEFORE_DATE);
+    QualityProfile newQP = qp(QP_NAME_1, LANGUAGE_KEY_1, AFTER_DATE);
+
+    // mock updated profile
+    qProfileStatusRepository.register(newQP.getQpKey(), UPDATED);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(existingQP), arrayOf(newQP));
+
+    when(qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(newQP, treeRootHolder.getRoot().getUuid())).thenThrow(new RuntimeException("error"));
+    var context = new TestComputationStepContext();
+
+    // when
+    underTest.execute(context);
+
+    // then
+    assertThat(logTester.logs(Level.ERROR)).containsExactly("Failed to generate 'change' event for Quality Profile " + newQP.getQpKey());
+    verify(eventRepository, never()).add(any(Event.class));
+  }
+
+  @Test
+  public void givenNoChangesFound_whenEventStep_thenDebugLogAndSkipEvent() {
+    // given
+    logTester.setLevel(Level.DEBUG);
+    QualityProfile existingQP = qp(QP_NAME_1, LANGUAGE_KEY_1, BEFORE_DATE);
+    QualityProfile newQP = qp(QP_NAME_1, LANGUAGE_KEY_1, AFTER_DATE);
+
+    // mock updated profile
+    qProfileStatusRepository.register(newQP.getQpKey(), UPDATED);
+    mockQualityProfileMeasures(treeRootHolder.getRoot(), arrayOf(existingQP), arrayOf(newQP));
+
+    when(qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(newQP, treeRootHolder.getRoot().getUuid())).thenReturn(Collections.emptyMap());
+    var context = new TestComputationStepContext();
+
+    // when
+    underTest.execute(context);
+
+    // then
+    assertThat(logTester.logs(Level.DEBUG)).containsExactly("No changes found for Quality Profile " + newQP.getQpKey() + ". Quality Profile event skipped.");
+    verify(eventRepository, never()).add(any(Event.class));
   }
 
   @Test
@@ -220,11 +314,11 @@ public class QualityProfileEventsStepTest {
     Date date = new Date();
     QualityProfile qp1 = qp(QP_NAME_2, LANGUAGE_KEY_1, date);
     QualityProfile qp2 = qp(QP_NAME_2, LANGUAGE_KEY_2, date);
-    QualityProfile qp3 = qp(QP_NAME_1, LANGUAGE_KEY_1, parseDateTime("2011-04-25T01:05:13+0100"));
-    QualityProfile qp3_updated = qp(QP_NAME_1, LANGUAGE_KEY_1, parseDateTime("2011-04-25T01:05:17+0100"));
+    QualityProfile qp3 = qp(QP_NAME_1, LANGUAGE_KEY_1, BEFORE_DATE);
+    QualityProfile qp3_updated = qp(QP_NAME_1, LANGUAGE_KEY_1, AFTER_DATE);
     QualityProfile qp4 = qp(QP_NAME_2, LANGUAGE_KEY_3, date);
 
-    mockMeasures(
+    mockQualityProfileMeasures(
       treeRootHolder.getRoot(),
       arrayOf(qp1, qp2, qp3),
       arrayOf(qp3_updated, qp2, qp4));
@@ -234,12 +328,14 @@ public class QualityProfileEventsStepTest {
     qProfileStatusRepository.register(qp3.getQpKey(), UPDATED);
     qProfileStatusRepository.register(qp4.getQpKey(), ADDED);
 
+    when(qualityProfileRuleChangeTextResolver.mapChangeToNumberOfRules(qp3_updated, treeRootHolder.getRoot().getUuid())).thenReturn(CHANGE_TO_NUMBER_OF_RULES_MAP);
+
     underTest.execute(new TestComputationStepContext());
 
     assertThat(events).extracting("name").containsOnly(
-      "Stop using '" + QP_NAME_2 + "' (" + LANGUAGE_KEY_1 + ")",
-      "Use '" + QP_NAME_2 + "' (" + LANGUAGE_KEY_3 + ")",
-      "Changes in '" + QP_NAME_1 + "' (" + LANGUAGE_KEY_1 + ")");
+      "Stop using \"" + QP_NAME_2 + "\" (" + LANGUAGE_KEY_1 + ")",
+      "Use \"" + QP_NAME_2 + "\" (" + LANGUAGE_KEY_3 + ")",
+      "\"" + QP_NAME_1 + "\" (" + LANGUAGE_KEY_1 + ") updated with " + RULE_CHANGE_TEXT);
   }
 
   private Language mockLanguageInRepository(String languageKey) {
@@ -261,16 +357,16 @@ public class QualityProfileEventsStepTest {
     when(languageRepository.find(anyString())).thenReturn(Optional.empty());
   }
 
-  private void mockMeasures(Component component, @Nullable QualityProfile[] previous, @Nullable QualityProfile[] current) {
+  private void mockQualityProfileMeasures(Component component, @Nullable QualityProfile[] previous, @Nullable QualityProfile[] current) {
     when(measureRepository.getBaseMeasure(component, qualityProfileMetric)).thenReturn(Optional.of(newMeasure(previous)));
     when(measureRepository.getRawMeasure(component, qualityProfileMetric)).thenReturn(Optional.of(newMeasure(current)));
   }
 
-  private static void verifyEvent(Event event, String expectedName, @Nullable String expectedData) {
+  private static void verifyEvent(Event event, String expectedName, @Nullable String expectedData, @Nullable String expectedDescription) {
     assertThat(event.getName()).isEqualTo(expectedName);
     assertThat(event.getData()).isEqualTo(expectedData);
     assertThat(event.getCategory()).isEqualTo(Event.Category.PROFILE);
-    assertThat(event.getDescription()).isNull();
+    assertThat(event.getDescription()).isEqualTo(expectedDescription);
   }
 
   private static QualityProfile qp(String qpName, String languageKey, Date date) {