diff --git a/README.md b/README.md index 6c8c484a..8f087e44 100644 --- a/README.md +++ b/README.md @@ -110,19 +110,19 @@ You can pass other configuration flags adding it to the `additionalParameters` l ## Full list of compiler flags -| Flag | Parameters | Required | -|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------| -| `-P:scapegoat:dataDir:` | Path to reports directory for the plugin. | true | -| `-P:scapegoat:disabledInspections:` | Colon separated list of disabled inspections (defaults to none). | false | -| `-P:scapegoat:enabledInspections:` | Colon separated list of enabled inspections (defaults to all). | false | -| `-P:scapegoat:customInspectors:` | Colon separated list of custom inspections. | false | -| `-P:scapegoat:ignoredFiles:` | Colon separated list of regexes to match files to ignore. | false | -| `-P:scapegoat:verbose:` | Boolean flag that enables/disables verbose console messages. | false | -| `-P:scapegoat:consoleOutput:` | Boolean flag that enables/disables console report output. | false | -| `-P:scapegoat:reports:` | Colon separated list of reports to generate. Valid options are `none`, `xml`, `html`, `scalastyle`, `markdown`, or `all`. | false | -| `-P:scapegoat:overrideLevels:` | Overrides the built in warning levels. Should be a colon separated list of `name=level` expressions. | false | -| `-P:scapegoat:sourcePrefix:` | Overrides source prefix if it differs from `src/main/scala`, for ex. `app/` for Play applications. | false | -| `-P:scapegoat:minimalLevel:` | Provides minimal level of inspection displayed in reports and in the console. | false | +| Flag | Parameters | Required | +|-------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|----------| +| `-P:scapegoat:dataDir:` | Path to reports directory for the plugin. | true | +| `-P:scapegoat:disabledInspections:` | Colon separated list of disabled inspections (defaults to none). | false | +| `-P:scapegoat:enabledInspections:` | Colon separated list of enabled inspections (defaults to all). | false | +| `-P:scapegoat:customInspectors:` | Colon separated list of custom inspections. | false | +| `-P:scapegoat:ignoredFiles:` | Colon separated list of regexes to match files to ignore. | false | +| `-P:scapegoat:verbose:` | Boolean flag that enables/disables verbose console messages. | false | +| `-P:scapegoat:consoleOutput:` | Boolean flag that enables/disables console report output. | false | +| `-P:scapegoat:reports:` | Colon separated list of reports to generate. Valid options are `none`, `xml`, `html`, `scalastyle`, `markdown`, ,`gitlab-codequality`, or `all`. | false | +| `-P:scapegoat:overrideLevels:` | Overrides the built in warning levels. Should be a colon separated list of `name=level` expressions. | false | +| `-P:scapegoat:sourcePrefix:` | Overrides source prefix if it differs from `src/main/scala`, for ex. `app/` for Play applications. | false | +| `-P:scapegoat:minimalLevel:` | Provides minimal level of inspection displayed in reports and in the console. | false | ## Reports diff --git a/src/main/scala/com/sksamuel/scapegoat/Configuration.scala b/src/main/scala/com/sksamuel/scapegoat/Configuration.scala index 653a3a29..7e6e2135 100644 --- a/src/main/scala/com/sksamuel/scapegoat/Configuration.scala +++ b/src/main/scala/com/sksamuel/scapegoat/Configuration.scala @@ -6,7 +6,8 @@ case class Reports( disableXML: Boolean, disableHTML: Boolean, disableScalastyleXML: Boolean, - disableMarkdown: Boolean + disableMarkdown: Boolean, + disableGitlabCodeQuality: Boolean ) case class Configuration( @@ -50,11 +51,14 @@ object Configuration { .map(inspection => Class.forName(inspection).getConstructor().newInstance().asInstanceOf[Inspection]) } val enabledReports = fromProperty("reports", defaultValue = Seq("all"))(_.split(':').toSeq) - val disableXML = !(enabledReports.contains("xml") || enabledReports.contains("all")) - val disableHTML = !(enabledReports.contains("html") || enabledReports.contains("all")) + def isReportEnabled(report: String): Boolean = + enabledReports.contains(report) || enabledReports.contains("all") + val disableXML = !isReportEnabled("xml") + val disableHTML = !isReportEnabled("html") val disableScalastyleXML = - !(enabledReports.contains("scalastyle") || enabledReports.contains("all")) - val disableMarkdown = !(enabledReports.contains("markdown") || enabledReports.contains("all")) + !isReportEnabled("scalastyle") + val disableMarkdown = !isReportEnabled("markdown") + val disableGitlabCodeQuality = !isReportEnabled("gitlab-codequality") val levelOverridesByInspectionSimpleName = fromProperty("overrideLevels", defaultValue = Map.empty[String, Level]) { @@ -97,7 +101,8 @@ object Configuration { disableXML = disableXML, disableHTML = disableHTML, disableScalastyleXML = disableScalastyleXML, - disableMarkdown = disableMarkdown + disableMarkdown = disableMarkdown, + disableGitlabCodeQuality = disableGitlabCodeQuality ), customInspectors = customInspectors, sourcePrefix = sourcePrefix, @@ -118,7 +123,7 @@ object Configuration { "-P:scapegoat:consoleOutput: enable/disable console report output", "-P:scapegoat:reports: colon separated list of reports to generate.", " Valid options are `xml', `html', `scalastyle', 'markdown',", - " or `all'. Use `none' to disable reports.", + " 'gitlab-codequality' or `all'. Use `none' to disable reports.", "-P:scapegoat:overrideLevels: override the built in warning levels, e.g. to", " downgrade a Error to a Warning.", " should be a colon separated list of name=level", diff --git a/src/main/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriter.scala b/src/main/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriter.scala new file mode 100644 index 00000000..ac680a90 --- /dev/null +++ b/src/main/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriter.scala @@ -0,0 +1,119 @@ +package com.sksamuel.scapegoat.io + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +import com.sksamuel.scapegoat.{Feedback, Levels, Warning} + +/** + * Supports GitLab Code Quality report format. + * + * https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool + */ +object GitlabCodeQualityReportWriter extends ReportWriter { + + override protected def fileName: String = "scapegoat-gitlab.json" + + override protected def generate(feedback: Feedback): String = { + val md5Digest = MessageDigest.getInstance("MD5") + toCodeQualityElements(feedback.warningsWithMinimalLevel, sys.env.get("CI_PROJECT_DIR"), md5Digest) + .map(_.toJsonArrayElement) + .mkString("[", ",", "]") + } + + private[io] def toCodeQualityElements( + warnings: Seq[Warning], + gitlabBuildDir: Option[String], + messageDigest: MessageDigest + ): Seq[CodeQualityReportElement] = warnings.map { warning => + // Stable hash for the same warning. + // Avoids moving code blocks around from causing "new" detecions. + val fingerprintRaw = warning.sourceFileNormalized + warning.snippet.getOrElse(warning.line.toString) + + messageDigest.reset() + messageDigest.update(fingerprintRaw.getBytes(StandardCharsets.UTF_8)) + val fingerprint = messageDigest + .digest() + .map("%02x".format(_)) + .mkString + + val severity = warning.level match { + case Levels.Error => CriticalSeverity + case Levels.Warning => MinorSeverity + case Levels.Info => InfoSeverity + case _ => InfoSeverity + } + + val gitlabCiNormalizedPath = gitlabBuildDir + .map { buildDir => + val fullBuildDir = if (buildDir.endsWith("/")) buildDir else s"$buildDir/" + val file = warning.sourceFileFull + if (file.startsWith(fullBuildDir)) file.drop(fullBuildDir.length) else file + } + .getOrElse(warning.sourceFileFull) + + val textStart = if (warning.explanation.startsWith(warning.text)) { + "" + } else { + if (warning.text.endsWith(".")) { + warning.text + " " + } else { + warning.text + ". " + } + } + val description = s"$textStart${warning.explanation}" + + CodeQualityReportElement( + description = description, + checkName = warning.inspection, + severity = severity, + location = Location(gitlabCiNormalizedPath, Lines(warning.line)), + fingerprint = fingerprint + ) + } +} + +sealed trait CodeClimateSeverity { + val name: String +} + +case object InfoSeverity extends CodeClimateSeverity { + override val name: String = "info" +} + +case object MinorSeverity extends CodeClimateSeverity { + override val name: String = "minor" +} + +case object CriticalSeverity extends CodeClimateSeverity { + override val name: String = "critical" +} + +final case class Location(path: String, lines: Lines) + +final case class Lines(begin: Int) + +final case class CodeQualityReportElement( + description: String, + checkName: String, + severity: CodeClimateSeverity, + location: Location, + fingerprint: String +) { + + // Manual templating is a bit silly but avoids a dependency on a potentially conflicting json library. + def toJsonArrayElement: String = + s""" + | { + | "description": "${description.replace("\"", "\\\"")}", + | "check_name": "$checkName", + | "fingerprint": "$fingerprint", + | "severity": "${severity.name}", + | "location": { + | "path": "${location.path}", + | "lines": { + | "begin": ${location.lines.begin} + | } + | } + | }""".stripMargin +} diff --git a/src/main/scala/com/sksamuel/scapegoat/io/IOUtils.scala b/src/main/scala/com/sksamuel/scapegoat/io/IOUtils.scala index dfed0081..3084aec1 100644 --- a/src/main/scala/com/sksamuel/scapegoat/io/IOUtils.scala +++ b/src/main/scala/com/sksamuel/scapegoat/io/IOUtils.scala @@ -20,4 +20,7 @@ object IOUtils { def writeMarkdownReport(targetDir: File, reporter: Feedback): File = MarkdownReportWriter.write(targetDir, reporter) + + def writeGitlabCodeQualityReport(targetDir: File, reporter: Feedback): File = + GitlabCodeQualityReportWriter.write(targetDir, reporter) } diff --git a/src/main/scala/com/sksamuel/scapegoat/plugin.scala b/src/main/scala/com/sksamuel/scapegoat/plugin.scala index 26979307..88ca96fd 100644 --- a/src/main/scala/com/sksamuel/scapegoat/plugin.scala +++ b/src/main/scala/com/sksamuel/scapegoat/plugin.scala @@ -96,6 +96,11 @@ class ScapegoatComponent(val global: Global, inspections: Seq[Inspection]) writeReport(reports.disableXML, "XML", IOUtils.writeXMLReport) writeReport(reports.disableScalastyleXML, "Scalastyle XML", IOUtils.writeScalastyleReport) writeReport(reports.disableMarkdown, "Markdown", IOUtils.writeMarkdownReport) + writeReport( + reports.disableGitlabCodeQuality, + "GitLab Code Quality", + IOUtils.writeGitlabCodeQualityReport + ) } } } diff --git a/src/test/scala/com/sksamuel/scapegoat/TestConfiguration.scala b/src/test/scala/com/sksamuel/scapegoat/TestConfiguration.scala index 4f473171..f89f591b 100644 --- a/src/test/scala/com/sksamuel/scapegoat/TestConfiguration.scala +++ b/src/test/scala/com/sksamuel/scapegoat/TestConfiguration.scala @@ -13,7 +13,8 @@ object TestConfiguration { disableXML = true, disableHTML = true, disableScalastyleXML = true, - disableMarkdown = true + disableMarkdown = true, + disableGitlabCodeQuality = true ), customInspectors = Seq(), sourcePrefix = "src/main/scala", diff --git a/src/test/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriterTest.scala b/src/test/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriterTest.scala new file mode 100644 index 00000000..08feafd7 --- /dev/null +++ b/src/test/scala/com/sksamuel/scapegoat/io/GitlabCodeQualityReportWriterTest.scala @@ -0,0 +1,66 @@ +package com.sksamuel.scapegoat.io + +import java.security.MessageDigest + +import com.sksamuel.scapegoat.{Levels, Warning} +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +class GitlabCodeQualityReportWriterTest extends AnyFreeSpec with Matchers { + + "GitlabCodeQualityReportWriter" - { + + "should transform feedback" in { + val warnings = Seq( + Warning( + "Use of Option.get", + 13, + Levels.Error, + "/home/johnnei/git/scapegoat/src/main/scala/com/sksamuel/File.scala", + "com.sksamuel.File.scala", + Some("File.this.d.get"), + "Using Option.get defeats the purpose", + "com.sksamuel.scapegoat.inspections.option.OptionGet" + ), + Warning( + "List.size is O(n)", + 13, + Levels.Info, + "/home/johnnei/git/scapegoat/src/main/scala/com/sksamuel/File.scala", + "com.sksamuel.File.scala", + None, + "List.size is O(n). Consider using...", + "com.sksamuel.scapegoat.inspections.collections.ListSize" + ) + ) + + val report = GitlabCodeQualityReportWriter + .toCodeQualityElements( + warnings, + Some("/home/johnnei/git/scapegoat"), + MessageDigest.getInstance("MD5") + ) + report should be( + Seq( + CodeQualityReportElement( + // Extra dot after warning text to improve readability + "Use of Option.get. Using Option.get defeats the purpose", + "com.sksamuel.scapegoat.inspections.option.OptionGet", + CriticalSeverity, + Location("src/main/scala/com/sksamuel/File.scala", Lines(13)), + "909b14c15a3a3891659251f133058264" + ), + CodeQualityReportElement( + // Warning text is trimmed to avoid duplicate text + "List.size is O(n). Consider using...", + "com.sksamuel.scapegoat.inspections.collections.ListSize", + InfoSeverity, + Location("src/main/scala/com/sksamuel/File.scala", Lines(13)), + "f79bc3223909939407272a1db37a6d17" + ) + ) + ) + } + } + +}