]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20552 Introduce new generic issue import format in scanner
authorAlain Kermis <alain.kermis@sonarsource.com>
Wed, 27 Sep 2023 14:41:05 +0000 (16:41 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 4 Oct 2023 20:03:19 +0000 (20:03 +0000)
26 files changed:
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OneExternalIssueCctPerLineSensor.java [new file with mode: 0644]
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OnePredefinedRuleExternalIssueCctPerLineSensor.java [new file with mode: 0644]
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/XooRulesDefinition.java
plugins/sonar-xoo-plugin/src/test/java/org/sonar/xoo/rule/XooRulesDefinitionTest.java
sonar-plugin-api-impl/src/main/java/org/sonar/api/batch/sensor/rule/internal/DefaultAdHocRule.java
sonar-scanner-engine/src/main/java/org/sonar/scanner/bootstrap/BatchComponents.java
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueImporter.java
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReport.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportParser.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportValidator.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssuesImportSensor.java
sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ReportParser.java [deleted file]
sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueImporterTest.java
sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportParserTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportValidatorTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ReportParserTest.java [deleted file]
sonar-scanner-engine/src/test/java/org/sonar/scanner/sensor/DefaultSensorStorageTest.java
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/invalid_report.json [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/report.json [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_engineId.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_filePath.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_message.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_primaryLocation.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_ruleId.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_severity.json [deleted file]
sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_type.json [deleted file]

diff --git a/plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OneExternalIssueCctPerLineSensor.java b/plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OneExternalIssueCctPerLineSensor.java
new file mode 100644 (file)
index 0000000..d1ea092
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * 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.xoo.rule;
+
+import org.sonar.api.batch.fs.FilePredicates;
+import org.sonar.api.batch.fs.FileSystem;
+import org.sonar.api.batch.fs.InputFile;
+import org.sonar.api.batch.sensor.Sensor;
+import org.sonar.api.batch.sensor.SensorContext;
+import org.sonar.api.batch.sensor.SensorDescriptor;
+import org.sonar.api.batch.sensor.issue.NewExternalIssue;
+import org.sonar.xoo.Xoo;
+
+import static org.sonar.api.issue.impact.Severity.LOW;
+import static org.sonar.api.issue.impact.Severity.MEDIUM;
+import static org.sonar.api.issue.impact.SoftwareQuality.MAINTAINABILITY;
+import static org.sonar.api.issue.impact.SoftwareQuality.RELIABILITY;
+import static org.sonar.api.rules.CleanCodeAttribute.CLEAR;
+
+public class OneExternalIssueCctPerLineSensor implements Sensor {
+  public static final String RULE_ID = "OneExternalIssueWithPerLineSensor";
+  public static final String ENGINE_ID = "XooEngine";
+  public static final Long EFFORT = 10L;
+  public static final String ACTIVATE = "sonar.oneExternalIssueWithPerLineSensor.activate";
+  public static final String REGISTER_AD_HOC_RULE = "sonar.oneExternalIssueWithPerLineSensor.adhocRule";
+  private static final String NAME = "One External Issue Per Line in Clean Code Taxonomy format";
+
+  @Override
+  public void describe(SensorDescriptor descriptor) {
+    descriptor
+      .name(NAME)
+      .onlyOnLanguages(Xoo.KEY)
+      .onlyWhenConfiguration(c -> c.getBoolean(ACTIVATE).orElse(false));
+  }
+
+  @Override
+  public void execute(SensorContext context) {
+    FileSystem fs = context.fileSystem();
+    FilePredicates p = fs.predicates();
+    for (InputFile file : fs.inputFiles(p.and(p.hasLanguages(Xoo.KEY), p.hasType(InputFile.Type.MAIN)))) {
+      createIssues(file, context);
+    }
+    if (context.config().getBoolean(REGISTER_AD_HOC_RULE).orElse(false)) {
+      context.newAdHocRule()
+        .engineId(ENGINE_ID)
+        .ruleId(RULE_ID)
+        .name("An ad hoc rule")
+        .description("blah blah")
+        .cleanCodeAttribute(CLEAR)
+        .addDefaultImpact(MAINTAINABILITY, MEDIUM)
+        .addDefaultImpact(RELIABILITY, LOW)
+        .save();
+    }
+  }
+
+  private static void createIssues(InputFile file, SensorContext context) {
+    for (int line = 1; line <= file.lines(); line++) {
+      NewExternalIssue newIssue = context.newExternalIssue();
+      newIssue
+        .engineId(ENGINE_ID)
+        .ruleId(RULE_ID)
+        .at(newIssue.newLocation()
+          .on(file)
+          .at(file.selectLine(line))
+          .message("This issue is generated on each line"))
+        .remediationEffortMinutes(EFFORT)
+        .save();
+    }
+  }
+}
diff --git a/plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OnePredefinedRuleExternalIssueCctPerLineSensor.java b/plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/rule/OnePredefinedRuleExternalIssueCctPerLineSensor.java
new file mode 100644 (file)
index 0000000..772c272
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * 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.xoo.rule;
+
+import org.sonar.api.batch.fs.FilePredicates;
+import org.sonar.api.batch.fs.FileSystem;
+import org.sonar.api.batch.fs.InputFile;
+import org.sonar.api.batch.fs.InputFile.Type;
+import org.sonar.api.batch.sensor.Sensor;
+import org.sonar.api.batch.sensor.SensorContext;
+import org.sonar.api.batch.sensor.SensorDescriptor;
+import org.sonar.api.batch.sensor.issue.NewExternalIssue;
+import org.sonar.xoo.Xoo;
+
+public class OnePredefinedRuleExternalIssueCctPerLineSensor implements Sensor {
+  public static final String RULE_ID = "OnePredefinedRuleExternalIssueCctPerLine";
+  public static final String ENGINE_ID = "XooEngine";
+  public static final Long EFFORT = 10L;
+  public static final String ACTIVATE = "sonar.onePredefinedRuleExternalIssueCctPerLine.activate";
+  private static final String NAME = "One External Issue Per Line With A Predefined Rule in Clean Code Taxonomy format";
+
+  @Override
+  public void describe(SensorDescriptor descriptor) {
+    descriptor
+      .name(NAME)
+      .onlyOnLanguages(Xoo.KEY)
+      .onlyWhenConfiguration(c -> c.getBoolean(ACTIVATE).orElse(false));
+  }
+
+  @Override
+  public void execute(SensorContext context) {
+    FileSystem fs = context.fileSystem();
+    FilePredicates p = fs.predicates();
+    for (InputFile file : fs.inputFiles(p.and(p.hasLanguages(Xoo.KEY), p.hasType(Type.MAIN)))) {
+      createIssues(file, context);
+    }
+  }
+
+  private static void createIssues(InputFile file, SensorContext context) {
+    for (int line = 1; line <= file.lines(); line++) {
+      NewExternalIssue newIssue = context.newExternalIssue();
+      newIssue
+        .engineId(ENGINE_ID)
+        .ruleId(RULE_ID)
+        .at(newIssue.newLocation()
+          .on(file)
+          .at(file.selectLine(line))
+          .message("This issue is generated on each line and the rule is predefined"))
+        .remediationEffortMinutes(EFFORT)
+        .save();
+    }
+  }
+}
index c7315d4d9913885b64ba548a4c220c50fccca143..6f5b2549ee0eecafa58fb62da96411e5c28948fd 100644 (file)
@@ -76,6 +76,7 @@ public class XooRulesDefinition implements RulesDefinition {
     defineRulesXoo(context);
     defineRulesXoo2(context);
     defineRulesXooExternal(context);
+    defineRulesXooExternalWithCct(context);
   }
 
   private static void defineRulesXoo2(Context context) {
@@ -319,6 +320,18 @@ public class XooRulesDefinition implements RulesDefinition {
     repo.done();
   }
 
+  private static void defineRulesXooExternalWithCct(Context context) {
+    NewRepository repo = context.createExternalRepository(OneExternalIssueCctPerLineSensor.ENGINE_ID, Xoo.KEY).setName(OneExternalIssueCctPerLineSensor.ENGINE_ID);
+
+    repo.createRule(OnePredefinedRuleExternalIssueCctPerLineSensor.RULE_ID)
+      .setScope(RuleScope.ALL)
+      .setHtmlDescription("Generates one external issue in each line")
+      .addDescriptionSection(descriptionSection(INTRODUCTION_SECTION_KEY, "Generates one external issue in each line"))
+      .setName("One external issue per line");
+
+    repo.done();
+  }
+
   private static void defineRulesXooExternal(Context context) {
     NewRepository repo = context.createExternalRepository(OneExternalIssuePerLineSensor.ENGINE_ID, Xoo.KEY).setName(OneExternalIssuePerLineSensor.ENGINE_ID);
 
index fa287bcd82cad91b84b56346cba32bb8cf4b72ed..679292b1fbe754589578829f69a4315a0adcea66 100644 (file)
@@ -102,7 +102,7 @@ public class XooRulesDefinitionTest {
     assertThat(repo).isNotNull();
     assertThat(repo.name()).isEqualTo("XooEngine");
     assertThat(repo.language()).isEqualTo("xoo");
-    assertThat(repo.rules()).hasSize(1);
+    assertThat(repo.rules()).hasSize(2);
   }
 
   @Test
index 389a81361f13b9581ba05afb73f7bd73c1d20785..a71e1eab76d65b19991e864e914558a379bdc90e 100644 (file)
@@ -109,13 +109,13 @@ public class DefaultAdHocRule extends DefaultStorable implements AdHocRule, NewA
 
   @Override
   public Map<SoftwareQuality, org.sonar.api.issue.impact.Severity> defaultImpacts() {
-    return impacts;
+    return impacts.isEmpty() ? Map.of(SoftwareQuality.MAINTAINABILITY, org.sonar.api.issue.impact.Severity.MEDIUM) : impacts;
   }
 
   @CheckForNull
   @Override
   public CleanCodeAttribute cleanCodeAttribute() {
-    return cleanCodeAttribute;
+    return cleanCodeAttribute == null ? CleanCodeAttribute.defaultCleanCodeAttribute() : cleanCodeAttribute;
   }
 
   @Override
index a8acb3840ef59ae06ae07b4f2dc25ccc57e28d58..37af9929445d1c05df22cd1da8c976433a0c9233 100644 (file)
@@ -27,6 +27,8 @@ import org.sonar.core.config.CorePropertyDefinitions;
 import org.sonar.core.sarif.SarifSerializerImpl;
 import org.sonar.scanner.cpd.JavaCpdBlockIndexerSensor;
 import org.sonar.scanner.deprecated.test.TestPlanBuilder;
+import org.sonar.scanner.externalissue.ExternalIssueReportParser;
+import org.sonar.scanner.externalissue.ExternalIssueReportValidator;
 import org.sonar.scanner.externalissue.ExternalIssuesImportSensor;
 import org.sonar.scanner.externalissue.sarif.DefaultSarif210Importer;
 import org.sonar.scanner.externalissue.sarif.LocationMapper;
@@ -59,6 +61,8 @@ public class BatchComponents {
     components.add(TestPlanBuilder.class);
 
     // External issues
+    components.add(ExternalIssueReportValidator.class);
+    components.add(ExternalIssueReportParser.class);
     components.add(ExternalIssuesImportSensor.class);
     components.add(ExternalIssuesImportSensor.properties());
     components.add(SarifSerializerImpl.class);
index 00eeb9a3a8a620ed09b9b554963f320b558cd445..c629025b1de4bd183faefa335068dfafa28b924f 100644 (file)
 package org.sonar.scanner.externalissue;
 
 import java.util.LinkedHashSet;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.CheckForNull;
+import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.sonar.api.batch.fs.InputFile;
 import org.sonar.api.batch.fs.TextPointer;
 import org.sonar.api.batch.rule.Severity;
 import org.sonar.api.batch.sensor.SensorContext;
 import org.sonar.api.batch.sensor.issue.NewExternalIssue;
 import org.sonar.api.batch.sensor.issue.NewIssueLocation;
+import org.sonar.api.batch.sensor.rule.NewAdHocRule;
+import org.sonar.api.issue.impact.SoftwareQuality;
+import org.sonar.api.rules.CleanCodeAttribute;
 import org.sonar.api.rules.RuleType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.scanner.externalissue.ReportParser.Issue;
-import org.sonar.scanner.externalissue.ReportParser.Location;
-import org.sonar.scanner.externalissue.ReportParser.Report;
+import org.sonar.api.server.rule.internal.ImpactMapper;
+import org.sonar.scanner.externalissue.ExternalIssueReport.Issue;
+import org.sonar.scanner.externalissue.ExternalIssueReport.Location;
+import org.sonar.scanner.externalissue.ExternalIssueReport.Rule;
 
 public class ExternalIssueImporter {
   private static final Logger LOG = LoggerFactory.getLogger(ExternalIssueImporter.class);
   private static final int MAX_UNKNOWN_FILE_PATHS_TO_PRINT = 5;
 
   private final SensorContext context;
-  private final Report report;
+  private final ExternalIssueReport report;
   private final Set<String> unknownFiles = new LinkedHashSet<>();
   private final Set<String> knownFiles = new LinkedHashSet<>();
 
-  public ExternalIssueImporter(SensorContext context, Report report) {
+  public ExternalIssueImporter(SensorContext context, ExternalIssueReport report) {
     this.context = context;
     this.report = report;
   }
 
   public void execute() {
-    int issueCount = 0;
+    if (report.rules != null) {
+      importRules();
+    } else {
+      importDeprecatedFormat();
+    }
+  }
 
+  private void importDeprecatedFormat() {
+    int issueCount = 0;
     for (Issue issue : report.issues) {
-      if (importIssue(issue)) {
+      if (importDeprecatedIssue(issue)) {
         issueCount++;
       }
     }
+    logStatistics(issueCount, StringUtils.EMPTY);
+  }
 
-    LOG.info("Imported {} {} in {} {}", issueCount, pluralize("issue", issueCount), knownFiles.size(), pluralize("file", knownFiles.size()));
-    int numberOfUnknownFiles = unknownFiles.size();
-    if (numberOfUnknownFiles > 0) {
-      LOG.info("External issues ignored for " + numberOfUnknownFiles + " unknown files, including: "
-        + unknownFiles.stream().limit(MAX_UNKNOWN_FILE_PATHS_TO_PRINT).collect(Collectors.joining(", ")));
+  private void importRules() {
+    for (Rule rule : report.rules) {
+      NewAdHocRule adHocRule = createAdHocRule(rule);
+      int issueCount = 0;
+      for (Issue issue : rule.issues) {
+        if (importIssue(issue, rule)) {
+          issueCount++;
+        }
+      }
+      logStatistics(issueCount, String.format(" for ruleId '%s'", rule.ruleId));
+      adHocRule.save();
     }
   }
 
-  private boolean importIssue(Issue issue) {
-    NewExternalIssue externalIssue = context.newExternalIssue()
-      .engineId(issue.engineId)
-      .ruleId(issue.ruleId)
-      .severity(Severity.valueOf(issue.severity))
-      .type(RuleType.valueOf(issue.type));
+  private NewAdHocRule createAdHocRule(Rule rule) {
+    NewAdHocRule adHocRule = context.newAdHocRule();
+    adHocRule.ruleId(rule.ruleId);
+    adHocRule.name(rule.name);
+    adHocRule.description(rule.description);
+    adHocRule.engineId(rule.engineId);
+    adHocRule.cleanCodeAttribute(CleanCodeAttribute.valueOf(rule.cleanCodeAttribute));
+    adHocRule.severity(backmapSeverityFromImpact(rule));
+    adHocRule.type(backmapTypeFromImpact(rule));
+    for (ExternalIssueReport.Impact impact : rule.impacts) {
+      adHocRule.addDefaultImpact(SoftwareQuality.valueOf(impact.softwareQuality),
+        org.sonar.api.issue.impact.Severity.valueOf(impact.severity));
+    }
+    return adHocRule;
+  }
 
+  private static RuleType backmapTypeFromImpact(Rule rule) {
+    return ImpactMapper.convertToRuleType(SoftwareQuality.valueOf(rule.impacts[0].softwareQuality));
+  }
+
+  private static Severity backmapSeverityFromImpact(Rule rule) {
+    org.sonar.api.issue.impact.Severity impactSeverity = org.sonar.api.issue.impact.Severity.valueOf(rule.impacts[0].severity);
+    return Severity.valueOf(ImpactMapper.convertToDeprecatedSeverity(impactSeverity));
+  }
+
+  private boolean populateCommonValues(Issue issue, NewExternalIssue externalIssue) {
     if (issue.effortMinutes != null) {
       externalIssue.remediationEffortMinutes(Long.valueOf(issue.effortMinutes));
     }
@@ -98,6 +138,37 @@ public class ExternalIssueImporter {
     }
   }
 
+  private boolean importDeprecatedIssue(Issue issue) {
+    NewExternalIssue externalIssue = context.newExternalIssue()
+      .engineId(issue.engineId)
+      .ruleId(issue.ruleId)
+      .severity(Severity.valueOf(issue.severity))
+      .type(RuleType.valueOf(issue.type));
+
+    return populateCommonValues(issue, externalIssue);
+  }
+
+  private boolean importIssue(Issue issue, ExternalIssueReport.Rule rule) {
+    NewExternalIssue externalIssue = context.newExternalIssue()
+      .engineId(rule.engineId)
+      .ruleId(rule.ruleId)
+      .severity(backmapSeverityFromImpact(rule))
+      .type(backmapTypeFromImpact(rule));
+
+    return populateCommonValues(issue, externalIssue);
+  }
+
+  private void logStatistics(int issueCount, String additionalInformation) {
+    String pluralizedIssues = pluralize("issue", issueCount);
+    String pluralizedFiles = pluralize("file", knownFiles.size());
+    LOG.info("Imported {} {} in {} {}{}", issueCount, pluralizedIssues, knownFiles.size(), pluralizedFiles, additionalInformation);
+    int numberOfUnknownFiles = unknownFiles.size();
+    if (numberOfUnknownFiles > 0) {
+      String limitedUnknownFiles = this.unknownFiles.stream().limit(MAX_UNKNOWN_FILE_PATHS_TO_PRINT).collect(Collectors.joining(", "));
+      LOG.info("External issues{} ignored for {} unknown files, including: {}", additionalInformation, numberOfUnknownFiles, limitedUnknownFiles);
+    }
+  }
+
   private static String pluralize(String msg, int count) {
     if (count == 1) {
       return msg;
@@ -123,12 +194,8 @@ public class ExternalIssueImporter {
         int endLine = (location.textRange.endLine != null) ? location.textRange.endLine : location.textRange.startLine;
         int endColumn;
 
-        if (location.textRange.endColumn == null) {
-          // assume it's until the last character of the end line
-          endColumn = file.selectLine(endLine).end().lineOffset();
-        } else {
-          endColumn = location.textRange.endColumn;
-        }
+        // assume it's until the last character of the end line
+        endColumn = Objects.requireNonNullElseGet(location.textRange.endColumn, () -> file.selectLine(endLine).end().lineOffset());
         TextPointer end = file.newPointer(endLine, endColumn);
         newLocation.at(file.newRange(start, end));
       } else {
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReport.java b/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReport.java
new file mode 100644 (file)
index 0000000..fcf1963
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * 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.scanner.externalissue;
+
+import javax.annotation.Nullable;
+
+public class ExternalIssueReport {
+  Issue[] issues;
+  Rule[] rules;
+
+  static class Issue {
+    @Nullable
+    String engineId;
+    @Nullable
+    String ruleId;
+    @Nullable
+    String severity;
+    @Nullable
+    String type;
+    @Nullable
+    Integer effortMinutes;
+    Location primaryLocation;
+    @Nullable
+    Location[] secondaryLocations;
+  }
+
+  static class Rule {
+    String ruleId;
+    String engineId;
+    String name;
+    @Nullable
+    String description;
+    String cleanCodeAttribute;
+    Impact[] impacts;
+    Issue[] issues;
+  }
+
+  static class Impact {
+    String severity;
+    String softwareQuality;
+  }
+
+  static class Location {
+    @Nullable
+    String message;
+    String filePath;
+    @Nullable
+    TextRange textRange;
+  }
+
+  static class TextRange {
+    Integer startLine;
+    @Nullable
+    Integer startColumn;
+    @Nullable
+    Integer endLine;
+    @Nullable
+    Integer endColumn;
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportParser.java b/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportParser.java
new file mode 100644 (file)
index 0000000..063f086
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * 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.scanner.externalissue;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonIOException;
+import com.google.gson.JsonSyntaxException;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.sonar.api.scanner.ScannerSide;
+
+@ScannerSide
+public class ExternalIssueReportParser {
+  private final Gson gson = new Gson();
+  private final ExternalIssueReportValidator externalIssueReportValidator;
+
+  public ExternalIssueReportParser(ExternalIssueReportValidator externalIssueReportValidator) {
+    this.externalIssueReportValidator = externalIssueReportValidator;
+  }
+
+  public ExternalIssueReport parse(Path reportPath) {
+    try (Reader reader = Files.newBufferedReader(reportPath, StandardCharsets.UTF_8)) {
+      ExternalIssueReport report = gson.fromJson(reader, ExternalIssueReport.class);
+      externalIssueReportValidator.validate(report, reportPath);
+      return report;
+    } catch (JsonIOException | IOException e) {
+      throw new IllegalStateException("Failed to read external issues report '" + reportPath + "'", e);
+    } catch (JsonSyntaxException e) {
+      throw new IllegalStateException("Failed to read external issues report '" + reportPath + "': invalid JSON syntax", e);
+    }
+  }
+
+
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportValidator.java b/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ExternalIssueReportValidator.java
new file mode 100644 (file)
index 0000000..d3b81b9
--- /dev/null
@@ -0,0 +1,144 @@
+/*
+ * 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.scanner.externalissue;
+
+import java.nio.file.Path;
+import javax.annotation.Nullable;
+import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.api.scanner.ScannerSide;
+import org.sonar.core.documentation.DocumentationLinkGenerator;
+
+@ScannerSide
+public class ExternalIssueReportValidator {
+  private static final Logger LOGGER = LoggerFactory.getLogger(ExternalIssueReportValidator.class);
+  private static final String RULE_ID = "ruleId";
+  private static final String SEVERITY = "severity";
+  private static final String TYPE = "type";
+  private static final String DOCUMENTATION_SUFFIX = "/analyzing-source-code/importing-external-issues/generic-issue-import-format/";
+  private final DocumentationLinkGenerator documentationLinkGenerator;
+
+  ExternalIssueReportValidator(DocumentationLinkGenerator documentationLinkGenerator) {
+    this.documentationLinkGenerator = documentationLinkGenerator;
+  }
+
+  public void validate(ExternalIssueReport report, Path reportPath) {
+    if (report.rules != null) {
+      checkNoField(report.issues, "issues", reportPath);
+      validateRules(report.rules, reportPath);
+    } else if (report.issues != null) {
+      String documentationLink = documentationLinkGenerator.getDocumentationLink(DOCUMENTATION_SUFFIX);
+      LOGGER.warn("External issues were imported with a deprecated format which will be removed soon. " +
+        "Please switch to the newest format to fully benefit from Clean Code: {}", documentationLink);
+      validateIssues(report.issues, reportPath);
+    } else {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field 'rules'", reportPath));
+    }
+  }
+
+  private static void validateIssues(ExternalIssueReport.Issue[] issues, Path reportPath) {
+    for (ExternalIssueReport.Issue issue : issues) {
+      mandatoryField(issue.ruleId, RULE_ID, reportPath);
+      mandatoryField(issue.severity, SEVERITY, reportPath);
+      mandatoryField(issue.type, TYPE, reportPath);
+      mandatoryField(issue.engineId, "engineId", reportPath);
+      validateAlwaysRequiredIssueFields(issue, reportPath);
+    }
+  }
+
+  private static void validateRules(ExternalIssueReport.Rule[] rules, Path reportPath) {
+    for (ExternalIssueReport.Rule rule : rules) {
+      mandatoryField(rule.ruleId, RULE_ID, reportPath);
+      mandatoryField(rule.name, "name", reportPath);
+      mandatoryField(rule.engineId, "engineId", reportPath);
+      mandatoryField(rule.cleanCodeAttribute, "cleanCodeAttribute", reportPath);
+      mandatoryArray(rule.impacts, "impacts", reportPath);
+      validateIssuesAsPartOfARule(rule.issues, reportPath);
+    }
+  }
+
+  private static void validateIssuesAsPartOfARule(ExternalIssueReport.Issue[] issues, Path reportPath) {
+    for (ExternalIssueReport.Issue issue : issues) {
+      validateAlwaysRequiredIssueFields(issue, reportPath);
+      checkNoField(issue.severity, SEVERITY, reportPath);
+      checkNoField(issue.type, TYPE, reportPath);
+      checkNoField(issue.ruleId, RULE_ID, reportPath);
+    }
+  }
+
+  private static void checkNoField(@Nullable Object value, String fieldName, Path reportPath) {
+    if (value != null) {
+      throw new IllegalStateException(String.format("Deprecated '%s' field found in the following report: '%s'.", fieldName, reportPath));
+    }
+  }
+
+  private static void validateAlwaysRequiredIssueFields(ExternalIssueReport.Issue issue, Path reportPath) {
+    mandatoryField(issue.primaryLocation, "primaryLocation", reportPath);
+    mandatoryFieldPrimaryLocation(issue.primaryLocation.filePath, "filePath", reportPath);
+    mandatoryFieldPrimaryLocation(issue.primaryLocation.message, "message", reportPath);
+
+    if (issue.primaryLocation.textRange != null) {
+      mandatoryFieldPrimaryLocation(issue.primaryLocation.textRange.startLine, "startLine of the text range", reportPath);
+    }
+
+    if (issue.secondaryLocations != null) {
+      for (ExternalIssueReport.Location l : issue.secondaryLocations) {
+        mandatoryFieldSecondaryLocation(l.filePath, "filePath", reportPath);
+        mandatoryFieldSecondaryLocation(l.textRange, "textRange", reportPath);
+        mandatoryFieldSecondaryLocation(l.textRange.startLine, "startLine of the text range", reportPath);
+      }
+    }
+  }
+
+  private static void mandatoryFieldPrimaryLocation(@Nullable Object value, String fieldName, Path reportPath) {
+    if (value == null) {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s' in the primary location of" +
+        " the issue.", reportPath, fieldName));
+    }
+  }
+
+  private static void mandatoryFieldSecondaryLocation(@Nullable Object value, String fieldName, Path reportPath) {
+    if (value == null) {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s' in a secondary location of" +
+        " the issue.", reportPath, fieldName));
+    }
+  }
+
+  private static void mandatoryField(@Nullable Object value, String fieldName, Path reportPath) {
+    if (value == null || (value instanceof String && ((String) value).isEmpty())) {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s'.", reportPath, fieldName));
+    }
+  }
+
+  private static void mandatoryArray(@Nullable Object[] value, String fieldName, Path reportPath) {
+    mandatoryField(value, fieldName, reportPath);
+    if (value.length == 0) {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': mandatory array '%s' not populated.", reportPath,
+        fieldName));
+    }
+  }
+
+  private static void mandatoryField(@Nullable String value, String fieldName, Path reportPath) {
+    if (StringUtils.isBlank(value)) {
+      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s'.", reportPath, fieldName));
+    }
+  }
+}
index 508523a45275cb14a9ebad3956965d99dbdfd016..b2f19d8561058ae9f2bdb3a490d5472be840496c 100644 (file)
@@ -25,6 +25,8 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.sonar.api.CoreProperties;
 import org.sonar.api.batch.sensor.Sensor;
 import org.sonar.api.batch.sensor.SensorContext;
@@ -32,18 +34,18 @@ import org.sonar.api.batch.sensor.SensorDescriptor;
 import org.sonar.api.config.Configuration;
 import org.sonar.api.config.PropertyDefinition;
 import org.sonar.api.resources.Qualifiers;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.scanner.externalissue.ReportParser.Report;
+import org.sonar.api.scanner.ScannerSide;
 
+@ScannerSide
 public class ExternalIssuesImportSensor implements Sensor {
   private static final Logger LOG = LoggerFactory.getLogger(ExternalIssuesImportSensor.class);
-  static final String REPORT_PATHS_PROPERTY_KEY = "sonar.externalIssuesReportPaths";
-
+  private static final String REPORT_PATHS_PROPERTY_KEY = "sonar.externalIssuesReportPaths";
   private final Configuration config;
+  private final ExternalIssueReportParser externalIssueReportParser;
 
-  public ExternalIssuesImportSensor(Configuration config) {
+  public ExternalIssuesImportSensor(Configuration config, ExternalIssueReportParser externalIssueReportParser) {
     this.config = config;
+    this.externalIssueReportParser = externalIssueReportParser;
   }
 
   public static List<PropertyDefinition> properties() {
@@ -68,8 +70,7 @@ public class ExternalIssuesImportSensor implements Sensor {
     for (String reportPath : reportPaths) {
       LOG.debug("Importing issues from '{}'", reportPath);
       Path reportFilePath = context.fileSystem().resolvePath(reportPath).toPath();
-      ReportParser parser = new ReportParser(reportFilePath);
-      Report report = parser.parse();
+      ExternalIssueReport report = externalIssueReportParser.parse(reportFilePath);
       ExternalIssueImporter issueImporter = new ExternalIssueImporter(context, report);
       issueImporter.execute();
     }
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ReportParser.java b/sonar-scanner-engine/src/main/java/org/sonar/scanner/externalissue/ReportParser.java
deleted file mode 100644 (file)
index 485f1ff..0000000
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * 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.scanner.externalissue;
-
-import com.google.gson.Gson;
-import com.google.gson.JsonIOException;
-import com.google.gson.JsonSyntaxException;
-import java.io.IOException;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import javax.annotation.Nullable;
-import org.apache.commons.lang.StringUtils;
-
-public class ReportParser {
-  private Gson gson = new Gson();
-  private Path filePath;
-
-  public ReportParser(Path filePath) {
-    this.filePath = filePath;
-  }
-
-  public Report parse() {
-    try (Reader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
-      return validate(gson.fromJson(reader, Report.class));
-    } catch (JsonIOException | IOException e) {
-      throw new IllegalStateException("Failed to read external issues report '" + filePath + "'", e);
-    } catch (JsonSyntaxException e) {
-      throw new IllegalStateException("Failed to read external issues report '" + filePath + "': invalid JSON syntax", e);
-    }
-  }
-
-  private Report validate(Report report) {
-    for (Issue issue : report.issues) {
-      mandatoryField(issue.primaryLocation, "primaryLocation");
-      mandatoryField(issue.engineId, "engineId");
-      mandatoryField(issue.ruleId, "ruleId");
-      mandatoryField(issue.severity, "severity");
-      mandatoryField(issue.type, "type");
-      mandatoryField(issue.primaryLocation, "primaryLocation");
-      mandatoryFieldPrimaryLocation(issue.primaryLocation.filePath, "filePath");
-      mandatoryFieldPrimaryLocation(issue.primaryLocation.message, "message");
-
-      if (issue.primaryLocation.textRange != null) {
-        mandatoryFieldPrimaryLocation(issue.primaryLocation.textRange.startLine, "startLine of the text range");
-      }
-      
-      if (issue.secondaryLocations != null) {
-        for (Location l : issue.secondaryLocations) {
-          mandatoryFieldSecondaryLocation(l.filePath, "filePath");
-          mandatoryFieldSecondaryLocation(l.textRange, "textRange");
-          mandatoryFieldSecondaryLocation(l.textRange.startLine, "startLine of the text range");
-        }
-      }
-    }
-
-    return report;
-  }
-
-  private void mandatoryFieldPrimaryLocation(@Nullable Object value, String fieldName) {
-    if (value == null) {
-      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s' in the primary location of the issue.", filePath, fieldName));
-    }
-  }
-  
-  private void mandatoryFieldSecondaryLocation(@Nullable Object value, String fieldName) {
-    if (value == null) {
-      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s' in a secondary location of the issue.", filePath, fieldName));
-    }
-  }
-
-  private void mandatoryField(@Nullable Object value, String fieldName) {
-    if (value == null) {
-      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s'.", filePath, fieldName));
-    }
-  }
-
-  private void mandatoryField(@Nullable String value, String fieldName) {
-    if (StringUtils.isBlank(value)) {
-      throw new IllegalStateException(String.format("Failed to parse report '%s': missing mandatory field '%s'.", filePath, fieldName));
-    }
-  }
-
-  static class Report {
-    Issue[] issues;
-
-    public Report() {
-      // http://stackoverflow.com/a/18645370/229031
-    }
-  }
-
-  static class Issue {
-    String engineId;
-    String ruleId;
-    String severity;
-    String type;
-    @Nullable
-    Integer effortMinutes;
-    Location primaryLocation;
-    @Nullable
-    Location[] secondaryLocations;
-
-    public Issue() {
-      // http://stackoverflow.com/a/18645370/229031
-    }
-  }
-
-  static class Location {
-    @Nullable
-    String message;
-    String filePath;
-    @Nullable
-    TextRange textRange;
-
-    public Location() {
-      // http://stackoverflow.com/a/18645370/229031
-    }
-  }
-
-  static class TextRange {
-    Integer startLine;
-    @Nullable
-    Integer startColumn;
-    @Nullable
-    Integer endLine;
-    @Nullable
-    Integer endColumn;
-
-    public TextRange() {
-      // http://stackoverflow.com/a/18645370/229031
-    }
-  }
-}
index 315991b93fb99c1c885b8468b2c5430dbdcf1e1d..7b29c33aa0b5e05cd45dfb8ecc8cd23a5a69689d 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.scanner.externalissue;
 
 import java.io.File;
+import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.commons.lang.math.RandomUtils;
 import org.junit.Before;
@@ -33,14 +34,29 @@ import org.sonar.api.batch.fs.internal.TestInputFileBuilder;
 import org.sonar.api.batch.rule.Severity;
 import org.sonar.api.batch.sensor.internal.SensorContextTester;
 import org.sonar.api.batch.sensor.issue.ExternalIssue;
+import org.sonar.api.batch.sensor.rule.AdHocRule;
+import org.sonar.api.issue.impact.SoftwareQuality;
+import org.sonar.api.rules.CleanCodeAttribute;
+import org.sonar.api.rules.RuleType;
 import org.sonar.api.testfixtures.log.LogTester;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.commons.lang.ObjectUtils.defaultIfNull;
 import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.sonar.api.issue.impact.Severity.HIGH;
+import static org.sonar.api.issue.impact.Severity.LOW;
+import static org.sonar.api.issue.impact.SoftwareQuality.MAINTAINABILITY;
+import static org.sonar.api.issue.impact.SoftwareQuality.SECURITY;
 
 public class ExternalIssueImporterTest {
+
+  public static final String RULE_ENGINE_ID = "some_rule_engine_id";
+  public static final String RULE_ID = "some_rule_id";
+  public static final String RULE_NAME = "some_rule_name";
+  public static final CleanCodeAttribute RULE_ATTRIBUTE = CleanCodeAttribute.FORMATTED;
+
   @Rule
   public TemporaryFolder temp = new TemporaryFolder();
   @Rule
@@ -63,30 +79,167 @@ public class ExternalIssueImporterTest {
   }
 
   @Test
-  public void import_zero_issues() {
-    ReportParser.Report report = new ReportParser.Report();
-    report.issues = new ReportParser.Issue[0];
+  public void execute_whenNewFormatWithZeroIssues() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Rule rule = createRule();
+    rule.issues = new ExternalIssueReport.Issue[0];
+    report.rules = new ExternalIssueReport.Rule[]{rule};
 
     ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
     underTest.execute();
 
+    assertThat(context.allExternalIssues()).isEmpty();
+    assertThat(context.allIssues()).isEmpty();
+    assertThat(logs.logs(Level.INFO)).contains("Imported 0 issues in 0 files for ruleId 'some_rule_id'");
+  }
+
+  @Test
+  public void execute_whenNewFormatWithMinimalInfo() {
+    ExternalIssueReport.Issue input = new ExternalIssueReport.Issue();
+    input.primaryLocation = new ExternalIssueReport.Location();
+    input.primaryLocation.filePath = sourceFile.getProjectRelativePath();
+    input.primaryLocation.message = randomAlphabetic(5);
+
+    runOn(input);
+
+    assertThat(context.allExternalIssues()).hasSize(1);
+    ExternalIssue output = context.allExternalIssues().iterator().next();
+    assertThat(output.engineId()).isEqualTo(RULE_ENGINE_ID);
+    assertThat(output.ruleId()).isEqualTo(RULE_ID);
+    assertThat(output.severity()).isEqualTo(Severity.CRITICAL); //backmapped
+    assertThat(output.type()).isEqualTo(RuleType.VULNERABILITY); //backmapped
+    assertThat(output.remediationEffort()).isNull();
+    assertThat(logs.logs(Level.INFO)).contains("Imported 1 issue in 1 file for ruleId 'some_rule_id'");
+    assertThat(context.allAdHocRules()).hasSize(1);
+
+    AdHocRule output1 = context.allAdHocRules().iterator().next();
+    assertThat(output1.ruleId()).isEqualTo(RULE_ID);
+    assertThat(output1.name()).isEqualTo(RULE_NAME);
+    assertThat(output1.engineId()).isEqualTo(RULE_ENGINE_ID);
+    assertThat(output1.severity()).isEqualTo(Severity.CRITICAL); //backmapped
+    assertThat(output1.type()).isEqualTo(RuleType.VULNERABILITY); //backmapped
+    assertThat(output1.cleanCodeAttribute()).isEqualTo(RULE_ATTRIBUTE);
+    assertThat(output1.defaultImpacts()).containsExactlyInAnyOrderEntriesOf(Map.of(SECURITY, HIGH, MAINTAINABILITY, LOW));
+  }
+
+  @Test
+  public void execute_whenNewFormatWithCompletePrimaryLocation() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
+    input.startLine = 1;
+    input.startColumn = 4;
+    input.endLine = 2;
+    input.endColumn = 3;
+
+    runOn(newIssue(input));
+
+    assertThat(context.allExternalIssues()).hasSize(1);
+    ExternalIssue output = context.allExternalIssues().iterator().next();
+    assertSameRange(input, output.primaryLocation().textRange());
+  }
+
+  @Test
+  public void execute_whenNewFormatWithNoColumns() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
+    input.startLine = 1;
+    input.startColumn = null;
+    input.endLine = 2;
+    input.endColumn = null;
+
+    runOn(newIssue(input));
+
+    assertThat(context.allExternalIssues()).hasSize(1);
+    TextRange got = context.allExternalIssues().iterator().next().primaryLocation().textRange();
+    assertThat(got.start().line()).isEqualTo(input.startLine);
+    assertThat(got.start().lineOffset()).isZero();
+    assertThat(got.end().line()).isEqualTo(input.startLine);
+    assertThat(got.end().lineOffset()).isEqualTo(sourceFile.selectLine(input.startLine).end().lineOffset());
+  }
+
+  @Test
+  public void execute_whenNewFormatWithStartButNotEndColumn() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
+    input.startLine = 1;
+    input.startColumn = 3;
+    input.endLine = 2;
+    input.endColumn = null;
+
+    runOn(newIssue(input));
+
+    assertThat(context.allExternalIssues()).hasSize(1);
+    TextRange got = context.allExternalIssues().iterator().next().primaryLocation().textRange();
+    assertThat(got.start().line()).isEqualTo(input.startLine);
+    assertThat(got.start().lineOffset()).isEqualTo(3);
+    assertThat(got.end().line()).isEqualTo(input.endLine);
+    assertThat(got.end().lineOffset()).isEqualTo(sourceFile.selectLine(input.endLine).end().lineOffset());
+  }
+
+  @Test
+  public void execute_whenNewFormatContainsNonExistentCleanCodeAttribute_shouldThrowException() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Rule rule = createRule("not_existent_attribute", MAINTAINABILITY.name(), HIGH.name());
+    rule.issues = new ExternalIssueReport.Issue[]{};
+    report.rules = new ExternalIssueReport.Rule[]{rule};
+
+    ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
+
+    assertThatThrownBy(underTest::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("No enum constant org.sonar.api.rules.CleanCodeAttribute.not_existent_attribute");
+  }
+
+  @Test
+  public void execute_whenNewFormatContainsNonExistentSoftwareQuality_shouldThrowException() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Rule rule = createRule(CleanCodeAttribute.CONVENTIONAL.name(), "not_existent_software_quality", HIGH.name());
+    rule.issues = new ExternalIssueReport.Issue[]{};
+    report.rules = new ExternalIssueReport.Rule[]{rule};
+
+    ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
+
+    assertThatThrownBy(underTest::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("No enum constant org.sonar.api.issue.impact.SoftwareQuality.not_existent_software_quality");
+  }
+
+  @Test
+  public void execute_whenNewFormatContainsNonExistentImpactSeverity_shouldThrowException() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Rule rule = createRule(CleanCodeAttribute.CONVENTIONAL.name(), SoftwareQuality.RELIABILITY.name(),
+      "not_existent_impact_severity");
+    rule.issues = new ExternalIssueReport.Issue[]{};
+    report.rules = new ExternalIssueReport.Rule[]{rule};
+
+    ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
+
+    assertThatThrownBy(underTest::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("No enum constant org.sonar.api.issue.impact.Severity.not_existent_impact_severity");
+  }
+
+  @Test
+  public void execute_whenDeprecatedFormatWithZeroIssues() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    report.issues = new ExternalIssueReport.Issue[0];
+
+    ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
+    underTest.execute();
     assertThat(context.allExternalIssues()).isEmpty();
     assertThat(context.allIssues()).isEmpty();
     assertThat(logs.logs(Level.INFO)).contains("Imported 0 issues in 0 files");
   }
 
   @Test
-  public void import_issue_with_minimal_info() {
-    ReportParser.Report report = new ReportParser.Report();
-    ReportParser.Issue input = new ReportParser.Issue();
+  public void execute_whenDeprecatedFormatWithMinimalInfo() {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Issue input = new ExternalIssueReport.Issue();
     input.engineId = "findbugs";
     input.ruleId = "123";
     input.severity = "CRITICAL";
     input.type = "BUG";
-    input.primaryLocation = new ReportParser.Location();
+    input.primaryLocation = new ExternalIssueReport.Location();
     input.primaryLocation.filePath = sourceFile.getProjectRelativePath();
     input.primaryLocation.message = randomAlphabetic(5);
-    report.issues = new ReportParser.Issue[] {input};
+    report.issues = new ExternalIssueReport.Issue[]{input};
 
     ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
     underTest.execute();
@@ -101,33 +254,29 @@ public class ExternalIssueImporterTest {
   }
 
   @Test
-  public void import_issue_with_complete_primary_location() {
-    ReportParser.TextRange input = new ReportParser.TextRange();
+  public void execute_whenDeprecatedFormatWithCompletePrimaryLocation() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
     input.startLine = 1;
     input.startColumn = 4;
     input.endLine = 2;
     input.endColumn = 3;
 
-    runOn(newIssue(input));
+    runOnDeprecatedFormat(newIssue(input));
 
     assertThat(context.allExternalIssues()).hasSize(1);
     ExternalIssue output = context.allExternalIssues().iterator().next();
     assertSameRange(input, output.primaryLocation().textRange());
   }
 
-  /**
-   * If columns are not defined, then issue is assumed to be on the full first line.
-   * The end line is ignored.
-   */
   @Test
-  public void import_issue_with_no_columns() {
-    ReportParser.TextRange input = new ReportParser.TextRange();
+  public void execute_whenDeprecatedFormatWithNoColumns() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
     input.startLine = 1;
     input.startColumn = null;
     input.endLine = 2;
     input.endColumn = null;
 
-    runOn(newIssue(input));
+    runOnDeprecatedFormat(newIssue(input));
 
     assertThat(context.allExternalIssues()).hasSize(1);
     TextRange got = context.allExternalIssues().iterator().next().primaryLocation().textRange();
@@ -137,18 +286,15 @@ public class ExternalIssueImporterTest {
     assertThat(got.end().lineOffset()).isEqualTo(sourceFile.selectLine(input.startLine).end().lineOffset());
   }
 
-  /**
-   * If end column is not defined, then issue is assumed to be until the last character of the end line.
-   */
   @Test
-  public void import_issue_with_start_but_not_end_column() {
-    ReportParser.TextRange input = new ReportParser.TextRange();
+  public void execute_whenDeprecatedFormatWithStartButNotEndColumn() {
+    ExternalIssueReport.TextRange input = new ExternalIssueReport.TextRange();
     input.startLine = 1;
     input.startColumn = 3;
     input.endLine = 2;
     input.endColumn = null;
 
-    runOn(newIssue(input));
+    runOnDeprecatedFormat(newIssue(input));
 
     assertThat(context.allExternalIssues()).hasSize(1);
     TextRange got = context.allExternalIssues().iterator().next().primaryLocation().textRange();
@@ -158,29 +304,59 @@ public class ExternalIssueImporterTest {
     assertThat(got.end().lineOffset()).isEqualTo(sourceFile.selectLine(input.endLine).end().lineOffset());
   }
 
-  private void assertSameRange(ReportParser.TextRange expected, TextRange got) {
+  private static ExternalIssueReport.Rule createRule() {
+    return createRule(RULE_ATTRIBUTE.name(), SECURITY.name(), HIGH.name());
+  }
+
+  private static ExternalIssueReport.Rule createRule(String cleanCodeAttribute, String softwareQuality, String impactSeverity) {
+    ExternalIssueReport.Rule rule = new ExternalIssueReport.Rule();
+    rule.ruleId = RULE_ID;
+    rule.name = RULE_NAME;
+    rule.engineId = RULE_ENGINE_ID;
+    rule.cleanCodeAttribute = cleanCodeAttribute;
+    ExternalIssueReport.Impact impact1 = new ExternalIssueReport.Impact();
+    impact1.severity = impactSeverity;
+    impact1.softwareQuality = softwareQuality;
+    ExternalIssueReport.Impact impact2 = new ExternalIssueReport.Impact();
+    impact2.severity = LOW.name();
+    impact2.softwareQuality = MAINTAINABILITY.name();
+    rule.impacts = new ExternalIssueReport.Impact[]{impact1, impact2};
+    return rule;
+  }
+
+  private void assertSameRange(ExternalIssueReport.TextRange expected, TextRange got) {
     assertThat(got.start().line()).isEqualTo(expected.startLine);
     assertThat(got.start().lineOffset()).isEqualTo(defaultIfNull(expected.startColumn, 0));
     assertThat(got.end().line()).isEqualTo(expected.endLine);
     assertThat(got.end().lineOffset()).isEqualTo(defaultIfNull(expected.endColumn, 0));
   }
 
-  private void runOn(ReportParser.Issue input) {
-    ReportParser.Report report = new ReportParser.Report();
-    report.issues = new ReportParser.Issue[] {input};
+  private void runOnDeprecatedFormat(ExternalIssueReport.Issue input) {
+    ExternalIssueReport report = new ExternalIssueReport();
+    report.issues = new ExternalIssueReport.Issue[]{input};
+
+    ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
+    underTest.execute();
+  }
+
+  private void runOn(ExternalIssueReport.Issue input) {
+    ExternalIssueReport report = new ExternalIssueReport();
+    ExternalIssueReport.Rule rule = createRule();
+    rule.issues = new ExternalIssueReport.Issue[]{input};
+    report.rules = new ExternalIssueReport.Rule[]{rule};
 
     ExternalIssueImporter underTest = new ExternalIssueImporter(this.context, report);
     underTest.execute();
   }
 
-  private ReportParser.Issue newIssue(@Nullable ReportParser.TextRange textRange) {
-    ReportParser.Issue input = new ReportParser.Issue();
+  private ExternalIssueReport.Issue newIssue(@Nullable ExternalIssueReport.TextRange textRange) {
+    ExternalIssueReport.Issue input = new ExternalIssueReport.Issue();
     input.engineId = randomAlphabetic(5);
     input.ruleId = randomAlphabetic(5);
     input.severity = "CRITICAL";
     input.type = "BUG";
     input.effortMinutes = RandomUtils.nextInt();
-    input.primaryLocation = new ReportParser.Location();
+    input.primaryLocation = new ExternalIssueReport.Location();
     input.primaryLocation.filePath = sourceFile.getProjectRelativePath();
     input.primaryLocation.message = randomAlphabetic(5);
     input.primaryLocation.textRange = textRange;
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportParserTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportParserTest.java
new file mode 100644 (file)
index 0000000..645ecf5
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * 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.scanner.externalissue;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Test;
+
+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.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class ExternalIssueReportParserTest {
+  private static final String DEPRECATED_REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/";
+  private static final String REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/cct/";
+  private final ExternalIssueReportValidator validator = mock(ExternalIssueReportValidator.class);
+  private final ExternalIssueReportParser externalIssueReportParser = new ExternalIssueReportParser(validator);
+  private Path reportPath;
+
+  @Test
+  public void parse_whenCorrectCctFormat_shouldParseCorrectly() {
+    reportPath = Paths.get(REPORTS_LOCATION + "report.json");
+
+    ExternalIssueReport report = externalIssueReportParser.parse(reportPath);
+
+    verify(validator).validate(report, reportPath);
+    assertCctReport(report);
+  }
+
+  @Test
+  public void parse_whenCorrectDeprecatedFormat_shouldParseCorrectly() {
+    reportPath = Paths.get(DEPRECATED_REPORTS_LOCATION + "report.json");
+
+    ExternalIssueReport report = externalIssueReportParser.parse(reportPath);
+
+    verify(validator).validate(report, reportPath);
+    assertDeprecatedReport(report);
+  }
+
+  @Test
+  public void parse_whenDoesntExist_shouldFail() {
+    reportPath = Paths.get("unknown.json");
+
+    assertThatThrownBy(() -> externalIssueReportParser.parse(reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to read external issues report 'unknown.json'");
+  }
+
+  @Test
+  public void parse_whenInvalidDeprecatedFormat_shouldFail() {
+    reportPath = Paths.get(DEPRECATED_REPORTS_LOCATION + "report_invalid_json.json");
+
+    assertThatThrownBy(() -> externalIssueReportParser.parse(reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to read external issues report 'src/test/resources/org/sonar/scanner/externalissue/report_invalid_json.json': " +
+        "invalid JSON syntax");
+  }
+
+  @Test
+  public void parse_whenValidatorThrowsException_shouldPropagate() {
+    reportPath = Paths.get(DEPRECATED_REPORTS_LOCATION + "report.json");
+
+    doThrow(new IllegalStateException("Just a dummy exception")).when(validator).validate(any(), eq(reportPath));
+
+    assertThatThrownBy(() -> externalIssueReportParser.parse(reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Just a dummy exception");
+  }
+
+  private static void assertDeprecatedReport(ExternalIssueReport report) {
+    assertThat(report.issues).hasSize(4);
+
+    ExternalIssueReport.Issue issue1 = report.issues[0];
+    assertThat(issue1.engineId).isEqualTo("eslint");
+    assertThat(issue1.ruleId).isEqualTo("rule1");
+    assertThat(issue1.severity).isEqualTo("MAJOR");
+    assertThat(issue1.effortMinutes).isEqualTo(40);
+    assertThat(issue1.type).isEqualTo("CODE_SMELL");
+    assertThat(issue1.primaryLocation.filePath).isEqualTo("file1.js");
+    assertThat(issue1.primaryLocation.message).isEqualTo("fix the issue here");
+    assertThat(issue1.primaryLocation.textRange.startColumn).isEqualTo(2);
+    assertThat(issue1.primaryLocation.textRange.startLine).isEqualTo(1);
+    assertThat(issue1.primaryLocation.textRange.endColumn).isEqualTo(4);
+    assertThat(issue1.primaryLocation.textRange.endLine).isEqualTo(3);
+    assertThat(issue1.secondaryLocations).isNull();
+
+    ExternalIssueReport.Issue issue2 = report.issues[3];
+    assertThat(issue2.engineId).isEqualTo("eslint");
+    assertThat(issue2.ruleId).isEqualTo("rule3");
+    assertThat(issue2.severity).isEqualTo("MAJOR");
+    assertThat(issue2.effortMinutes).isNull();
+    assertThat(issue2.type).isEqualTo("BUG");
+    assertThat(issue2.secondaryLocations).hasSize(2);
+    assertThat(issue2.secondaryLocations[0].filePath).isEqualTo("file1.js");
+    assertThat(issue2.secondaryLocations[0].message).isEqualTo("fix the bug here");
+    assertThat(issue2.secondaryLocations[0].textRange.startLine).isEqualTo(1);
+    assertThat(issue2.secondaryLocations[1].filePath).isEqualTo("file2.js");
+    assertThat(issue2.secondaryLocations[1].message).isNull();
+    assertThat(issue2.secondaryLocations[1].textRange.startLine).isEqualTo(2);
+  }
+
+  private static void assertCctReport(ExternalIssueReport report) {
+    assertThat(report.issues).isNull();
+    assertThat(report.rules).hasSize(2);
+
+    ExternalIssueReport.Rule rule = report.rules[0];
+    assertThat(rule.ruleId).isEqualTo("rule1");
+    assertThat(rule.engineId).isEqualTo("test");
+    assertThat(rule.name).isEqualTo("just_some_rule_name");
+    assertThat(rule.description).isEqualTo("just_some_description");
+    assertThat(rule.cleanCodeAttribute).isEqualTo("FORMATTED");
+    assertThat(rule.impacts).hasSize(2);
+    assertThat(rule.impacts[0].severity).isEqualTo("HIGH");
+    assertThat(rule.impacts[0].softwareQuality).isEqualTo("MAINTAINABILITY");
+    assertThat(rule.impacts[1].severity).isEqualTo("LOW");
+    assertThat(rule.impacts[1].softwareQuality).isEqualTo("SECURITY");
+
+    assertThat(rule.issues).hasSize(4);
+
+    ExternalIssueReport.Issue issue1 = rule.issues[0];
+    assertThat(issue1.engineId).isNull();
+    assertThat(issue1.ruleId).isNull();
+    assertThat(issue1.severity).isNull();
+    assertThat(issue1.effortMinutes).isEqualTo(40);
+    assertThat(issue1.type).isNull();
+    assertThat(issue1.primaryLocation.filePath).isEqualTo("file1.js");
+    assertThat(issue1.primaryLocation.message).isEqualTo("fix the issue here");
+    assertThat(issue1.primaryLocation.textRange.startColumn).isEqualTo(2);
+    assertThat(issue1.primaryLocation.textRange.startLine).isEqualTo(1);
+    assertThat(issue1.primaryLocation.textRange.endColumn).isEqualTo(4);
+    assertThat(issue1.primaryLocation.textRange.endLine).isEqualTo(3);
+    assertThat(issue1.secondaryLocations).isNull();
+
+    ExternalIssueReport.Issue issue2 = rule.issues[3];
+    assertThat(issue2.engineId).isNull();
+    assertThat(issue2.ruleId).isNull();
+    assertThat(issue2.severity).isNull();
+    assertThat(issue2.effortMinutes).isNull();
+    assertThat(issue2.type).isNull();
+    assertThat(issue2.secondaryLocations).hasSize(2);
+    assertThat(issue2.secondaryLocations[0].filePath).isEqualTo("file1.js");
+    assertThat(issue2.secondaryLocations[0].message).isEqualTo("fix the bug here");
+    assertThat(issue2.secondaryLocations[0].textRange.startLine).isEqualTo(1);
+    assertThat(issue2.secondaryLocations[1].filePath).isEqualTo("file2.js");
+    assertThat(issue2.secondaryLocations[1].message).isNull();
+    assertThat(issue2.secondaryLocations[1].textRange.startLine).isEqualTo(2);
+  }
+
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportValidatorTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ExternalIssueReportValidatorTest.java
new file mode 100644 (file)
index 0000000..8f7bd5a
--- /dev/null
@@ -0,0 +1,379 @@
+/*
+ * 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.scanner.externalissue;
+
+import com.google.gson.Gson;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.slf4j.event.Level;
+import org.sonar.api.testfixtures.log.LogAndArguments;
+import org.sonar.api.testfixtures.log.LogTester;
+import org.sonar.core.documentation.DocumentationLinkGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ExternalIssueReportValidatorTest {
+
+  private static final String DEPRECATED_REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/";
+  private static final String REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/cct/";
+  private static final String URL = "/analyzing-source-code/importing-external-issues/generic-issue-import-format/";
+  private static final String TEST_URL = "test-url";
+
+  private final Gson gson = new Gson();
+  private final Path reportPath = Paths.get("some-folder");
+  private final DocumentationLinkGenerator documentationLinkGenerator = mock(DocumentationLinkGenerator.class);
+  private final ExternalIssueReportValidator validator = new ExternalIssueReportValidator(documentationLinkGenerator);
+
+  @Rule
+  public LogTester logTester = new LogTester();
+
+  @Before
+  public void setUp() {
+    when(documentationLinkGenerator.getDocumentationLink(URL)).thenReturn(TEST_URL);
+  }
+
+  @Test
+  public void validate_whenCorrect_shouldNotThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    assertThatCode(() -> validator.validate(report, reportPath)).doesNotThrowAnyException();
+  }
+
+  @Test
+  public void validate_whenMissingMandatoryCleanCodeAttributeField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].cleanCodeAttribute = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'cleanCodeAttribute'.");
+  }
+
+  @Test
+  public void validate_whenMissingEngineIdField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].engineId = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'engineId'.");
+  }
+
+  @Test
+  public void validate_whenMissingFilepathFieldForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].primaryLocation.filePath = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'filePath' in the primary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenMissingImpactsField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].impacts = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'impacts'.");
+  }
+
+  @Test
+  public void validate_whenMissingMessageFieldForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].primaryLocation.message = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'message' in the primary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenMissingStartLineFieldForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].primaryLocation.textRange.startLine = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'startLine of the text range' in the primary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenReportMissingFilePathForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[3].secondaryLocations[0].filePath = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'filePath' in a secondary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenReportMissingTextRangeForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[3].secondaryLocations[0].textRange = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'textRange' in a secondary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenReportMissingTextRangeStartLineForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[3].secondaryLocations[0].textRange.startLine = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'startLine of the text range' in a secondary location of the issue.");
+  }
+
+  @Test
+  public void validate_whenMissingNameField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].name = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'name'.");
+  }
+
+  @Test
+  public void validate_whenMissingPrimaryLocationField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].primaryLocation = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'primaryLocation'.");
+  }
+
+  @Test
+  public void validate_whenMissingRuleIdField_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].ruleId = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'ruleId'.");
+  }
+
+  @Test
+  public void validate_whenContainsDeprecatedIssuesEntry_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.issues = new ExternalIssueReport.Issue[] { new ExternalIssueReport.Issue() };
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Deprecated 'issues' field found in the following report: 'some-folder'.");
+  }
+
+  @Test
+  public void validate_whenContainsDeprecatedSeverityEntry_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].severity = "MAJOR";
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Deprecated 'severity' field found in the following report: 'some-folder'.");
+  }
+
+  @Test
+  public void validate_whenContainsDeprecatedTypeEntry_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].issues[0].type = "BUG";
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Deprecated 'type' field found in the following report: 'some-folder'.");
+  }
+
+  @Test
+  public void validate_whenContainsEmptyImpacts_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].impacts = new ExternalIssueReport.Impact[0];
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': mandatory array 'impacts' not populated.");
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportFormat_shouldValidateWithWarningLog() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    assertThatCode(() -> validator.validate(report, reportPath)).doesNotThrowAnyException();
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingEngineId_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].engineId = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'engineId'.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingFilepathForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].primaryLocation.filePath = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'filePath' in the primary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingMessageForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].primaryLocation.message = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'message' in the primary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].primaryLocation = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'primaryLocation'.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingStartLineForPrimaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].primaryLocation.textRange.startLine = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'startLine of the text range' in the primary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingFilePathForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[3].secondaryLocations[0].filePath = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'filePath' in a secondary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingTextRangeForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[3].secondaryLocations[0].textRange = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'textRange' in a secondary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingTextRangeStartLineForSecondaryLocation_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[3].secondaryLocations[0].textRange.startLine = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'startLine of the text range' in a secondary location of the issue.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingRuleId_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].ruleId = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'ruleId'.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingSeverity_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].severity = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'severity'.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenDeprecatedReportMissingType_shouldThrowException() throws IOException {
+    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
+    report.issues[0].type = null;
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'type'.");
+    assertWarningLog();
+  }
+
+  @Test
+  public void validate_whenEmptyStringForMandatoryField_shouldStillThrowException() throws IOException {
+    ExternalIssueReport report = read(REPORTS_LOCATION);
+    report.rules[0].ruleId = "";
+
+    assertThatThrownBy(() -> validator.validate(report, reportPath))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Failed to parse report 'some-folder': missing mandatory field 'ruleId'.");
+  }
+
+  private void assertWarningLog() {
+    assertThat(logTester.getLogs(Level.WARN))
+      .extracting(LogAndArguments::getFormattedMsg)
+      .contains("External issues were imported with a deprecated format which will be removed soon. " +
+        "Please switch to the newest format to fully benefit from Clean Code: " + TEST_URL);
+  }
+
+  private ExternalIssueReport read(String location) throws IOException {
+    Reader reader = Files.newBufferedReader(Paths.get(location + "report.json"), StandardCharsets.UTF_8);
+    return gson.fromJson(reader, ExternalIssueReport.class);
+  }
+
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ReportParserTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scanner/externalissue/ReportParserTest.java
deleted file mode 100644 (file)
index fdf41c8..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * 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.scanner.externalissue;
-
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import org.junit.Test;
-import org.sonar.scanner.externalissue.ReportParser.Report;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-public class ReportParserTest {
-
-  @Test
-  public void parse_sample() {
-    ReportParser parser = new ReportParser(Paths.get("src/test/resources/org/sonar/scanner/externalissue/report.json"));
-
-    Report report = parser.parse();
-
-    assertThat(report.issues).hasSize(4);
-    assertThat(report.issues[0].engineId).isEqualTo("eslint");
-    assertThat(report.issues[0].ruleId).isEqualTo("rule1");
-    assertThat(report.issues[0].severity).isEqualTo("MAJOR");
-    assertThat(report.issues[0].effortMinutes).isEqualTo(40);
-    assertThat(report.issues[0].type).isEqualTo("CODE_SMELL");
-    assertThat(report.issues[0].primaryLocation.filePath).isEqualTo("file1.js");
-    assertThat(report.issues[0].primaryLocation.message).isEqualTo("fix the issue here");
-    assertThat(report.issues[0].primaryLocation.textRange.startColumn).isEqualTo(2);
-    assertThat(report.issues[0].primaryLocation.textRange.startLine).isOne();
-    assertThat(report.issues[0].primaryLocation.textRange.endColumn).isEqualTo(4);
-    assertThat(report.issues[0].primaryLocation.textRange.endLine).isEqualTo(3);
-    assertThat(report.issues[0].secondaryLocations).isNull();
-
-    assertThat(report.issues[3].engineId).isEqualTo("eslint");
-    assertThat(report.issues[3].ruleId).isEqualTo("rule3");
-    assertThat(report.issues[3].severity).isEqualTo("MAJOR");
-    assertThat(report.issues[3].effortMinutes).isNull();
-    assertThat(report.issues[3].type).isEqualTo("BUG");
-    assertThat(report.issues[3].secondaryLocations).hasSize(2);
-    assertThat(report.issues[3].secondaryLocations[0].filePath).isEqualTo("file1.js");
-    assertThat(report.issues[3].secondaryLocations[0].message).isEqualTo("fix the bug here");
-    assertThat(report.issues[3].secondaryLocations[0].textRange.startLine).isOne();
-    assertThat(report.issues[3].secondaryLocations[1].filePath).isEqualTo("file2.js");
-    assertThat(report.issues[3].secondaryLocations[1].message).isNull();
-    assertThat(report.issues[3].secondaryLocations[1].textRange.startLine).isEqualTo(2);
-  }
-
-  private Path path(String reportName) {
-    return Paths.get("src/test/resources/org/sonar/scanner/externalissue/" + reportName);
-  }
-
-  @Test
-  public void fail_if_report_doesnt_exist() {
-    ReportParser parser = new ReportParser(Paths.get("unknown.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessage("Failed to read external issues report 'unknown.json'");
-  }
-
-  @Test
-  public void fail_if_report_is_not_valid_json() {
-    ReportParser parser = new ReportParser(path("report_invalid_json.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("invalid JSON syntax");
-  }
-
-  @Test
-  public void fail_if_primaryLocation_not_set() {
-    ReportParser parser = new ReportParser(path("report_missing_primaryLocation.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'primaryLocation'");
-  }
-
-  @Test
-  public void fail_if_engineId_not_set() {
-    ReportParser parser = new ReportParser(path("report_missing_engineId.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'engineId'");
-  }
-
-  @Test
-  public void fail_if_ruleId_not_set() {
-    ReportParser parser = new ReportParser(path("report_missing_ruleId.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'ruleId'");
-  }
-
-  @Test
-  public void fail_if_severity_not_set() {
-    ReportParser parser = new ReportParser(path("report_missing_severity.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'severity'");
-  }
-
-  @Test
-  public void fail_if_type_not_set() {
-    ReportParser parser = new ReportParser(path("report_missing_type.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'type'");
-  }
-
-  @Test
-  public void fail_if_filePath_not_set_in_primaryLocation() {
-    ReportParser parser = new ReportParser(path("report_missing_filePath.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'filePath'");
-  }
-
-  @Test
-  public void fail_if_message_not_set_in_primaryLocation() {
-    ReportParser parser = new ReportParser(path("report_missing_message.json"));
-
-    assertThatThrownBy(() -> parser.parse())
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("missing mandatory field 'message'");
-  }
-}
index 46cc94f03cf37d819cb4cb1b33269ca3e0b2b7c2..2ecaaf94948812cb556e683a601db98df5ee018f 100644 (file)
@@ -356,7 +356,7 @@ public class DefaultSensorStorageTest {
   }
 
   @Test
-  public void store_whenAdhocRuleIsSpecified_shouldWriteAdhocRuleToReport() throws IOException {
+  public void store_whenAdhocRuleIsSpecified_shouldWriteAdhocRuleToReport() {
 
     underTest.store(new DefaultAdHocRule().ruleId("ruleId").engineId("engineId")
       .name("name")
@@ -369,9 +369,11 @@ public class DefaultSensorStorageTest {
 
     try (CloseableIterator<ScannerReport.AdHocRule> adhocRuleIt = reportReader.readAdHocRules()) {
       ScannerReport.AdHocRule adhocRule = adhocRuleIt.next();
-      assertThat(adhocRule).extracting(r -> r.getRuleId(), r -> r.getName(), r -> r.getSeverity(), r -> r.getType(), r -> r.getDescription())
+      assertThat(adhocRule)
+        .extracting(ScannerReport.AdHocRule::getRuleId, ScannerReport.AdHocRule::getName, ScannerReport.AdHocRule::getSeverity,
+          ScannerReport.AdHocRule::getType, ScannerReport.AdHocRule::getDescription)
         .containsExactlyInAnyOrder("ruleId", "name", Constants.Severity.MAJOR, ScannerReport.IssueType.CODE_SMELL, "description");
-      assertThat(adhocRule.getDefaultImpactsList()).hasSize(2).extracting(i -> i.getSoftwareQuality(), i -> i.getSeverity())
+      assertThat(adhocRule.getDefaultImpactsList()).hasSize(2).extracting(ScannerReport.Impact::getSoftwareQuality, ScannerReport.Impact::getSeverity)
         .containsExactlyInAnyOrder(
           Tuple.tuple(SoftwareQuality.MAINTAINABILITY.name(), Severity.HIGH.name()),
           Tuple.tuple(SoftwareQuality.RELIABILITY.name(), Severity.MEDIUM.name()));
@@ -381,16 +383,17 @@ public class DefaultSensorStorageTest {
   }
 
   @Test
-  public void store_whenAdhocRuleIsSpecifiedWithOptionalFieldEmpty_shouldWriteAdhocRuleToReport() throws IOException {
+  public void store_whenAdhocRuleIsSpecifiedWithOptionalFieldEmpty_shouldWriteAdhocRuleWithDefaultImpactsToReport() {
     underTest.store(new DefaultAdHocRule().ruleId("ruleId").engineId("engineId")
       .name("name")
       .description("description"));
     try (CloseableIterator<ScannerReport.AdHocRule> adhocRuleIt = reportReader.readAdHocRules()) {
       ScannerReport.AdHocRule adhocRule = adhocRuleIt.next();
-      assertThat(adhocRule).extracting(r -> r.getSeverity(), r -> r.getType())
+      assertThat(adhocRule).extracting(ScannerReport.AdHocRule::getSeverity, ScannerReport.AdHocRule::getType)
         .containsExactlyInAnyOrder(Constants.Severity.UNSET_SEVERITY, ScannerReport.IssueType.UNSET);
-      assertThat(adhocRule.getDefaultImpactsList()).isEmpty();
-      assertThat(adhocRule.getCleanCodeAttribute()).isNullOrEmpty();
+      assertThat(adhocRule.getDefaultImpactsList()).extracting(ScannerReport.Impact::getSoftwareQuality, ScannerReport.Impact::getSeverity)
+        .containsExactlyInAnyOrder(Tuple.tuple(SoftwareQuality.MAINTAINABILITY.name(), Severity.MEDIUM.name()));
+      assertThat(adhocRule.getCleanCodeAttribute()).isEqualTo(CleanCodeAttribute.CONVENTIONAL.name());
     }
   }
 }
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/invalid_report.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/invalid_report.json
new file mode 100644 (file)
index 0000000..6e858fd
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "some_other_field": "with_some_values"
+}
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/report.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/cct/report.json
new file mode 100644 (file)
index 0000000..7f797d6
--- /dev/null
@@ -0,0 +1,136 @@
+{
+  "rules": [
+    {
+      "ruleId": "rule1",
+      "name": "just_some_rule_name",
+      "description": "just_some_description",
+      "engineId": "test",
+      "cleanCodeAttribute": "FORMATTED",
+      "impacts": [
+        {
+          "softwareQuality": "MAINTAINABILITY",
+          "severity": "HIGH"
+        },
+        {
+          "softwareQuality": "SECURITY",
+          "severity": "LOW"
+        }
+      ],
+      "issues": [
+        {
+          "effortMinutes": 40,
+          "primaryLocation": {
+            "message": "fix the issue here",
+            "filePath": "file1.js",
+            "textRange": {
+              "startLine": 1,
+              "startColumn": 2,
+              "endLine": 3,
+              "endColumn": 4
+            }
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file2.js",
+            "textRange": {
+              "startLine": 3
+            }
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file3.js"
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file3.js"
+          },
+          "secondaryLocations": [
+            {
+              "message": "fix the bug here",
+              "filePath": "file1.js",
+              "textRange": {
+                "startLine": 1
+              }
+            },
+            {
+              "filePath": "file2.js",
+              "textRange": {
+                "startLine": 2
+              }
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "ruleId": "rule2",
+      "name": "just_some_other_rule_name",
+      "description": "just_some_description",
+      "engineId": "test2",
+      "cleanCodeAttribute": "IDENTIFIABLE",
+      "impacts": [
+        {
+          "softwareQuality": "RELIABILITY",
+          "severity": "LOW"
+        }
+      ],
+      "issues": [
+        {
+          "effortMinutes": 40,
+          "primaryLocation": {
+            "message": "fix the issue here",
+            "filePath": "file1.js",
+            "textRange": {
+              "startLine": 1,
+              "startColumn": 2,
+              "endLine": 3,
+              "endColumn": 4
+            }
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file2.js",
+            "textRange": {
+              "startLine": 3
+            }
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file3.js"
+          }
+        },
+        {
+          "primaryLocation": {
+            "message": "fix the bug here",
+            "filePath": "file3.js"
+          },
+          "secondaryLocations": [
+            {
+              "message": "fix the bug here",
+              "filePath": "file1.js",
+              "textRange": {
+                "startLine": 1
+              }
+            },
+            {
+              "filePath": "file2.js",
+              "textRange": {
+                "startLine": 2
+              }
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_engineId.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_engineId.json
deleted file mode 100644 (file)
index 990fa88..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "ruleId": "rule2",
-    "severity": "MAJOR",
-    "type": "BUG",
-    "primaryLocation": {
-      "message": "fix the bug here",
-      "filePath": "file2.js",
-      "textRange": {
-        "startLine": 3
-      }
-    }     
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_filePath.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_filePath.json
deleted file mode 100644 (file)
index aa2edd2..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule2",
-    "severity": "MAJOR",
-    "type": "BUG",
-    "primaryLocation": {
-      "message": "fix the bug here"
-    }     
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_message.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_message.json
deleted file mode 100644 (file)
index c191559..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule2",
-    "severity": "MAJOR",
-    "type": "BUG",
-    "primaryLocation": {
-      "filePath": "file1.js"
-    }     
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_primaryLocation.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_primaryLocation.json
deleted file mode 100644 (file)
index e86cd2b..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule2",
-    "severity": "MAJOR",
-    "type": "BUG"
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_ruleId.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_ruleId.json
deleted file mode 100644 (file)
index bbd5b85..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "severity": "MAJOR",
-    "type": "BUG",
-    "primaryLocation": {
-      "message": "fix the bug here",
-      "filePath": "file2.js",
-      "textRange": {
-        "startLine": 3,
-        "endLine": 4
-      }
-    }     
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_severity.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_severity.json
deleted file mode 100644 (file)
index b69d9fb..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule2",
-    "type": "BUG",
-    "primaryLocation": {
-      "message": "fix the bug here",
-      "filePath": "file2.js",
-      "textRange": {
-        "startLine": 3,
-        "endLine": 4
-      }
-    }     
-  }
-]
-}
-   
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_type.json b/sonar-scanner-engine/src/test/resources/org/sonar/scanner/externalissue/report_missing_type.json
deleted file mode 100644 (file)
index 7321073..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-{
-"issues" : [
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule1",
-    "severity": "MAJOR",
-    "type": "CODE_SMELL",
-    "primaryLocation": {
-      "message": "fix the issue here",
-      "filePath": "file1.js",
-      "textRange": {
-        "startLine": 1,
-        "endLine": 2
-      }
-    }     
-  },
-  { 
-    "engineId": "eslint",
-    "ruleId": "rule2",
-    "severity": "MAJOR",
-    "primaryLocation": {
-      "message": "fix the bug here",
-      "filePath": "file2.js",
-      "textRange": {
-        "startLine": 3,
-        "endLine": 4
-      }
-    }     
-  }
-]
-}
-