Skip to content

Commit

Permalink
gradle-plugin: add task to merge sarif reports (#1456)
Browse files Browse the repository at this point in the history
### What's done:
* Added task to merge SARIF reports

This pull request closes #1452
  • Loading branch information
petertrr authored Jul 25, 2022
1 parent 40a51a9 commit befc9e9
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 33 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,21 +211,15 @@ diktat {
}
```

Also `diktat` extension has different reporters. You can specify `json`, `html`, `sarif`, `plain` (default) or your own custom reporter (it should be added as a dependency into `diktat` configuration):
Also in `diktat` extension you can configure different reporters and their output. You can specify `json`, `html`, `sarif`, `plain` (default).
If `output` is set, it should be a file path. If not set, results will be printed to stdout.
```kotlin
diktat {
// since 1.2.1 to keep in line with maven properties
reporter = "json" // "html", "json", "plain" (default), "sarif"
// before 1.2.1
// reporterType = "json" // "html", "json", "plain" (default), "sarif"
}
```

You can also specify an output.
```kotlin
diktat {
// since 1.2.1 (reporterType for old versions)
reporter = "json"
output = "someFile.json"
}
```
Expand Down Expand Up @@ -302,7 +296,7 @@ Diktat can be run via spotless-maven-plugin since version 2.8.0
```
</details>

## GitHub Native Integration
## GitHub Integration
We suggest everyone to use common ["sarif"](https://docs.oasis-open.org/sarif/sarif/v2.0/sarif-v2.0.html) format as a `reporter` (`reporterType`) in CI/CD.
GitHub has an [integration](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning)
with SARIF format and provides you a native reporting of diktat issues in Pull Requests.
Expand Down Expand Up @@ -337,6 +331,18 @@ mvn -B diktat:check@diktat -Ddiktat.githubActions=true
with:
sarif_file: ${{ github.workspace }}
```
*Note*: `codeql-action/upload-sarif` limits the number of uploaded files at 15. If your project has more than 15 subprojects,
the limit will be exceeded and the step will fail. To solve this issue one can merge SARIF reports.

`diktat-gradle-plugin` provides this capability with `mergeDiktatReports` task. This task aggregates reports of all diktat tasks
of all Gradle project, which produce SARIF reports, and outputs the merged report into root project's build directory. Then this single
file can be used as an input for Github action:
```yaml
with:
sarif_file: build/reports/diktat/diktat-merged.sarif
```

</details>

## <a name="config"></a> Customizations via `diktat-analysis.yml`
Expand Down
1 change: 1 addition & 0 deletions diktat-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ val junitVersion = project.properties.getOrDefault("junitVersion", "5.8.1") as S
val jacocoVersion = project.properties.getOrDefault("jacocoVersion", "0.8.7") as String
dependencies {
implementation(kotlin("gradle-plugin-api"))
implementation("io.github.detekt.sarif4k:sarif4k:0.0.1")

implementation("org.cqfn.diktat:diktat-common:$diktatVersion") {
exclude("org.jetbrains.kotlin", "kotlin-compiler-embeddable")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.cqfn.diktat.plugin.gradle

import org.cqfn.diktat.plugin.gradle.tasks.configureMergeReportsTask
import generated.DIKTAT_VERSION
import generated.KTLINT_VERSION
import org.gradle.api.Plugin
Expand All @@ -26,9 +27,7 @@ class DiktatGradlePlugin : Plugin<Project> {
diktatConfigFile = project.rootProject.file("diktat-analysis.yml")
}

// only gradle 7+ (or maybe 6.8) will embed kotlin 1.4+, kx.serialization is incompatible with kotlin 1.3, so until then we have to use JavaExec wrapper
// FixMe: when gradle with kotlin 1.4 is out, proper configurable tasks should be added
// configuration to provide JavaExec with correct classpath
// Configuration that will be used as classpath for JavaExec task.
val diktatConfiguration = project.configurations.create(DIKTAT_CONFIGURATION) { configuration ->
configuration.isVisible = false
configuration.dependencies.add(project.dependencies.create("com.pinterest:ktlint:$KTLINT_VERSION", closureOf<ExternalModuleDependency> {
Expand All @@ -47,6 +46,7 @@ class DiktatGradlePlugin : Plugin<Project> {

project.registerDiktatCheckTask(diktatExtension, diktatConfiguration, patternSet)
project.registerDiktatFixTask(diktatExtension, diktatConfiguration, patternSet)
project.configureMergeReportsTask(diktatExtension)
}

companion object {
Expand All @@ -70,6 +70,11 @@ class DiktatGradlePlugin : Plugin<Project> {
*/
const val DIKTAT_FIX_TASK = "diktatFix"

/**
* Name of the task that merges SARIF reports of diktat tasks
*/
internal const val MERGE_SARIF_REPORTS_TASK_NAME = "mergeDiktatReports"

/**
* Version of JVM with more strict module system, which requires `add-opens` for kotlin compiler
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ import org.gradle.api.tasks.util.PatternSet
import org.gradle.util.GradleVersion

import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import javax.inject.Inject

/**
Expand Down Expand Up @@ -148,27 +146,21 @@ open class DiktatJavaExecTaskBase @Inject constructor(
@Suppress("FUNCTION_BOOLEAN_PREFIX")
override fun getIgnoreFailures(): Boolean = ignoreFailuresProp.getOrElse(false)

@Suppress("AVOID_NULL_CHECKS")
private fun reporterFlag(diktatExtension: DiktatExtension): String = buildString {
val reporterFlag = project.createReporterFlag(diktatExtension)
append(reporterFlag)
val isSarifReporterActive = reporterFlag.contains("sarif")
if (isSarifReporterActive) {
if (isSarifReporterActive(reporterFlag)) {
// need to set user.home specially for ktlint, so it will be able to put a relative path URI in SARIF
systemProperty("user.home", project.rootDir.toString())
}

val outFlag = when {
// githubActions should have higher priority than a custom input
diktatExtension.githubActions -> {
val reportDir = Files.createDirectories(Paths.get("${project.buildDir}/reports/diktat"))
outputs.dir(reportDir)
",output=${reportDir.resolve("diktat.sarif")}"
}
diktatExtension.output.isNotEmpty() -> ",output=${diktatExtension.output}"
else -> ""
val outputFile = project.getOutputFile(diktatExtension)
if (outputFile != null) {
outputs.file(outputFile)
val outFlag = ",output=$outputFile"
append(outFlag)
}

append(outFlag)
}

@Suppress("MagicNumber")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ package org.cqfn.diktat.plugin.gradle

import groovy.lang.Closure
import org.gradle.api.Project
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths

@Suppress(
"MISSING_KDOC_TOP_LEVEL",
Expand Down Expand Up @@ -38,18 +41,18 @@ fun <T> Any.closureOf(action: T.() -> Unit): Closure<Any?> =
KotlinClosure1(action, this, this)

/**
* Create CLI flag to select reporter based on [diktatExtension]
* Create CLI flag to set reporter for ktlint based on [diktatExtension].
* [DiktatExtension.githubActions] should have higher priority than a custom input.
*
* @param diktatExtension project extension of type [DiktatExtension]
* @return CLI flag
* @param diktatExtension extension of type [DiktatExtension]
* @return CLI flag as string
*/
internal fun Project.createReporterFlag(diktatExtension: DiktatExtension): String {
fun Project.createReporterFlag(diktatExtension: DiktatExtension): String {
val name = diktatExtension.reporter.trim()
val validReporters = listOf("sarif", "plain", "json", "html")
val reporterFlag = when {
diktatExtension.githubActions -> {
if (diktatExtension.reporter.isNotEmpty()) {
// githubActions should have higher priority than custom input
logger.warn("`diktat.githubActions` is set to true, so custom reporter [$name] will be ignored and SARIF reporter will be used")
}
"--reporter=sarif"
Expand All @@ -67,3 +70,27 @@ internal fun Project.createReporterFlag(diktatExtension: DiktatExtension): Strin

return reporterFlag
}

/**
* Get destination file for Diktat report or null if stdout is used.
* [DiktatExtension.githubActions] should have higher priority than a custom input.
*
* @param diktatExtension extension of type [DiktatExtension]
* @return destination [File] or null if stdout is used
*/
internal fun Project.getOutputFile(diktatExtension: DiktatExtension): File? = when {
diktatExtension.githubActions -> {
val reportDir = Files.createDirectories(Paths.get("${project.buildDir}/reports/diktat"))
reportDir.resolve("diktat.sarif").toFile()
}
diktatExtension.output.isNotEmpty() -> file(diktatExtension.output)
else -> null
}

/**
* Whether SARIF reporter is enabled or not
*
* @param reporterFlag
* @return whether SARIF reporter is enabled
*/
internal fun isSarifReporterActive(reporterFlag: String) = reporterFlag.contains("sarif")
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package org.cqfn.diktat.plugin.gradle.tasks

import org.cqfn.diktat.plugin.gradle.DiktatExtension
import org.cqfn.diktat.plugin.gradle.DiktatGradlePlugin.Companion.MERGE_SARIF_REPORTS_TASK_NAME
import org.cqfn.diktat.plugin.gradle.DiktatJavaExecTaskBase
import org.cqfn.diktat.plugin.gradle.createReporterFlag
import org.cqfn.diktat.plugin.gradle.getOutputFile
import org.cqfn.diktat.plugin.gradle.isSarifReporterActive
import io.github.detekt.sarif4k.SarifSchema210
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.TaskExecutionException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

/**
* A task to merge SARIF reports produced by diktat check / diktat fix tasks.
*/
abstract class SarifReportMergeTask : DefaultTask() {
/**
* Source reports that should be merged
*/
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val input: ConfigurableFileCollection

/**
* Destination for the merged report
*/
@get:OutputFile
abstract val output: RegularFileProperty

/**
* @throws TaskExecutionException if failed to deserialize SARIF
*/
@TaskAction
fun mergeReports() {
val sarifReports = input.files
.filter { it.exists() }
.also { logger.info("Merging SARIF reports from files $it") }
.map {
try {
Json.decodeFromString<SarifSchema210>(it.readText())
} catch (e: SerializationException) {
logger.error("Couldn't deserialize JSON: is ${it.canonicalPath} a SARIF file?")
throw TaskExecutionException(this, e)
}
}

if (sarifReports.isEmpty()) {
logger.warn("Cannot perform merging of SARIF reports because no matching files were found; " +
"Is SARIF reporter active?"
)
return
}

// All reports should contain identical metadata, so we are using the first one as a base.
val templateReport = sarifReports.first()
val allResults = sarifReports.flatMap { sarifSchema ->
sarifSchema.runs
.flatMap { it.results.orEmpty() }
}
val mergedSarif = templateReport.copy(
runs = listOf(templateReport.runs.first().copy(results = allResults))
)

output.get().asFile.writeText(Json.encodeToString(mergedSarif))
}
}

/**
* @param diktatExtension extension of type [DiktatExtension]
*/
internal fun Project.configureMergeReportsTask(diktatExtension: DiktatExtension) {
if (path == rootProject.path) {
tasks.register(MERGE_SARIF_REPORTS_TASK_NAME, SarifReportMergeTask::class.java) { reportMergeTask ->
val diktatReportsDir = "${project.buildDir}/reports/diktat"
val mergedReportFile = project.file("$diktatReportsDir/diktat-merged.sarif")
reportMergeTask.outputs.file(mergedReportFile)
reportMergeTask.output.set(mergedReportFile)
}
}
val reportMergeTaskTaskProvider = rootProject.tasks.named(MERGE_SARIF_REPORTS_TASK_NAME, SarifReportMergeTask::class.java) { reportMergeTask ->
if (isSarifReporterActive(createReporterFlag(diktatExtension))) {
getOutputFile(diktatExtension)?.let { reportMergeTask.input.from(it) }
reportMergeTask.shouldRunAfter(tasks.withType(DiktatJavaExecTaskBase::class.java))
}
}
tasks.withType(DiktatJavaExecTaskBase::class.java).configureEach { diktatJavaExecTaskBase ->
diktatJavaExecTaskBase.finalizedBy(reportMergeTaskTaskProvider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class DiktatJavaExecTaskTest {
@Test
fun `check command line has reporter type and output`() {
assertCommandLineEquals(
listOf(null, "--reporter=json,output=some.txt")
listOf(null, "--reporter=json,output=${project.projectDir.resolve("some.txt")}")
) {
inputs { exclude("*") }
diktatConfigFile = project.file("../diktat-analysis.yml")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.cqfn.diktat.plugin.gradle

import org.gradle.api.Project
import org.gradle.api.tasks.util.PatternSet
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class ReporterSelectionTest {
private val projectBuilder = ProjectBuilder.builder()
private lateinit var project: Project

@BeforeEach
fun setUp() {
project = projectBuilder.build()
// mock kotlin sources
project.mkdir("src/main/kotlin")
project.file("src/main/kotlin/Test.kt").createNewFile()
project.pluginManager.apply(DiktatGradlePlugin::class.java)
}

@Test
fun `should fallback to plain reporter for unknown reporter types`() {
val diktatExtension = DiktatExtension(PatternSet()).apply {
reporter = "jsonx"
}

Assertions.assertEquals(
"--reporter=plain",
project.createReporterFlag(diktatExtension)
)
}
}

0 comments on commit befc9e9

Please sign in to comment.