Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add task versionPolicyExportCompatibilityReport to export the compatibility reports in a machine-readable format (JSON) #189

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ This plugin:

- configures [MiMa] to check for binary or source incompatibilities,
- ensures that none of your dependencies are bumped or removed in an incompatible way,
- reports incompatibilities with previous releases.
- reports incompatibilities with previous releases,
- sets the [`versionScheme`](https://www.scala-sbt.org/1.x/docs/Publishing.html#Version+scheme) of the project to `"early-semver"`.

## Install

Expand Down Expand Up @@ -260,6 +261,36 @@ able to assess the compatibility level of the current state of the project with

We demonstrate the “unconstrained” mode in [this example](./sbt-version-policy/src/sbt-test/sbt-version-policy/example-sbt-release-unconstrained).

### How to generate compatibility reports?

You can export the compatibility reports in JSON format with the task `versionPolicyExportCompatibilityReport`.

1. It does not matter whether `versionPolicyIntention` is set or not. If it is set, the report will list the incompatibilities that violate the intended compatibility level. If it is not set, all the incompatibilities will be reported.
2. Invoke the task `versionPolicyExportCompatibilityReport` on the module you want to generate a report for. For example, for the default root module:
~~~ shell
sbt versionPolicyExportCompatibilityReport
~~~
The task automatically aggregates the compatibility reports of all its aggregated submodules.
3. Read the file `target/scala-2.13/compatibility-report.json` (or `target/scala-3/compatibility-report.json`).
You can see an example of compatibility report [here](./sbt-version-policy/src/sbt-test/sbt-version-policy/export-compatibility-report/expected-compatibility-report.json).

Here are examples of how to read some specific fields of the compatibility report with `jq`:
~~~ shell
# Get the highest compatibility level satisfied by all the aggregated modules.
# Returns either 'incompatible', 'binary-compatible', or 'binary-and-source-compatible'.
cat compatibility-report.json | jq '.aggregated.compatibility.value'

# Get a human-readable description of the highest compatibility level sastisfied
# by all the aggregated modules.
cat compatibility-report.json | jq '.aggregated.compatibility.label'

# Get the version of the project against which the compatibility level
# was assessed.
cat compatibility-report.json | jq '.aggregated.modules[0]."previous-version"'
# Or, in the case of a single module report (no aggregated submodules):
cat compatibility-report.json | jq '."previous-version"'
~~~

## How does `versionPolicyCheck` work?

The `versionPolicyCheck` task:
Expand Down
11 changes: 10 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,25 @@ lazy val root = (project in file("."))
)

lazy val `sbt-version-policy` = project
.enablePlugins(SbtPlugin)
.enablePlugins(SbtPlugin, ShadingPlugin)
.settings(
scriptedLaunchOpts += "-Dplugin.version=" + version.value,
scriptedBufferLog := false,
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.3"),
libraryDependencies ++= Seq(
"io.get-coursier" % "interface" % "1.0.19",
"io.get-coursier" %% "versions" % "0.3.1",
"com.lihaoyi" %% "ujson" % "3.1.3",
"com.eed3si9n.verify" %% "verify" % "2.0.1" % Test,
),
shadedModules += "com.lihaoyi" %% "ujson",
shadingRules ++= Seq(
ShadingRule.moveUnder("ujson", "sbtversionpolicy.internal.shaded"),
ShadingRule.moveUnder("upickle", "sbtversionpolicy.internal.shaded"),
ShadingRule.moveUnder("geny", "sbtversionpolicy.internal.shaded"),
),
validNamespaces ++= Set("sbtversionpolicy", "com", "com.typesafe", "sbt"),
validEntries += "utf8.json",
testFrameworks += new TestFramework("verify.runner.Framework"),
mimaBinaryIssueFilters ++= Seq(
// Add Mima filters here
Expand Down
2 changes: 2 additions & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12")
libraryDependencies += "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value

addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "3.1.0")

addSbtPlugin("io.get-coursier" % "sbt-shading" % "2.1.1")
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sbtversionpolicy

import coursier.version.{ Version, VersionCompatibility }
import com.typesafe.tools.mima.core.Problem
import coursier.version.{Version, VersionCompatibility}
import sbt.VersionNumber

/** Compatibility level between two version values.
Expand Down Expand Up @@ -60,6 +61,22 @@ object Compatibility {
}
}

def fromIssues(dependencyIssues: DependencyCheckReport, apiIssues: Seq[(IncompatibilityType, Problem)]): Compatibility = {
if (
dependencyIssues.validated(IncompatibilityType.SourceIncompatibility) &&
apiIssues.isEmpty
) {
Compatibility.BinaryAndSourceCompatible
} else if (
dependencyIssues.validated(IncompatibilityType.BinaryIncompatibility) &&
!apiIssues.exists(_._1 == IncompatibilityType.BinaryIncompatibility)
) {
Compatibility.BinaryCompatible
} else {
Compatibility.None
}
}

/**
* Validates that the given new `version` matches the claimed `compatibility` level.
* @return Some validation error, or None if the version is valid.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package sbtversionpolicy

import sbt.*
import com.typesafe.tools.mima.core.Problem
import upickle.core.LinkedHashMap

/**
* @param moduleReport Compatibility report for one module
* @param submoduleReports Compatibility reports for the aggregated submodules
*/
case class CompatibilityReport(
moduleReport: Option[CompatibilityModuleReport],
submoduleReports: Option[(Compatibility, Seq[CompatibilityReport])]
)

/**
* @param previousRelease Module ID of the previous release of this module, against which the compatibility was assessed
* @param compatibility Assessed compatibility level based on both dependency issues and API issues
* @param dependencyIssues Dependency issues found for this module
* @param apiIssues API issues (ie, Mima issue) found for this module
*/
case class CompatibilityModuleReport(
previousRelease: ModuleID,
compatibility: Compatibility,
dependencyIssues: DependencyCheckReport,
apiIssues: Seq[(IncompatibilityType, Problem)]
)

object CompatibilityReport {

def write(
targetFile: File,
compatibilityReport: CompatibilityReport,
log: Logger,
compatibilityLabel: Compatibility => String = defaultCompatibilityLabels
): Unit = {
IO.createDirectory(targetFile.getParentFile)
IO.write(targetFile, ujson.write(toJson(compatibilityReport, compatibilityLabel), indent = 2))
log.info(s"Wrote compatibility report in ${targetFile.absolutePath}")
}

// Human readable description of the compatibility levels
val defaultCompatibilityLabels: Compatibility => String = {
case Compatibility.None => "Incompatible"
case Compatibility.BinaryCompatible => "Binary compatible"
case Compatibility.BinaryAndSourceCompatible => "Binary and source compatible"
}

private def toJson(report: CompatibilityReport, compatibilityLabel: Compatibility => String): ujson.Value = {
val fields = LinkedHashMap[String, ujson.Value]()
report.moduleReport.foreach { moduleReport =>
fields ++= Seq(
"module-name" -> moduleReport.previousRelease.name,
"previous-version" -> moduleReport.previousRelease.revision,
"compatibility" -> toJson(moduleReport.compatibility, compatibilityLabel),
// TODO add issue details
// "issues" -> ujson.Obj("dependencies" -> ujson.Arr(), "api" -> ujson.Obj())
)
}
report.submoduleReports.foreach { case (aggregatedCompatibility, submoduleReports) =>
fields += "aggregated" -> ujson.Obj(
"compatibility" -> toJson(aggregatedCompatibility, compatibilityLabel),
"modules" -> ujson.Arr(submoduleReports.map(toJson(_, compatibilityLabel))*)
)
}
ujson.Obj(fields)
}

private def toJson(compatibility: Compatibility, compatibilityLabel: Compatibility => String): ujson.Value =
ujson.Obj(
"value" -> (compatibility match {
case Compatibility.None => ujson.Str("incompatible")
case Compatibility.BinaryCompatible => ujson.Str("binary-compatible")
case Compatibility.BinaryAndSourceCompatible => ujson.Str("binary-and-source-compatible")
}),
"label" -> ujson.Str(compatibilityLabel(compatibility))
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ trait SbtVersionPolicyInternalKeys {

final val versionPolicyVersionCompatibility = settingKey[VersionCompatibility]("VersionCompatibility used to determine compatibility.")
final val versionPolicyVersionCompatResult = taskKey[Compatibility]("Calculated level of compatibility required according to the current project version and the versioning scheme.")

final def versionPolicyCollectCompatibilityReports = TaskKey[CompatibilityReport]("versionPolicyCollectCompatibilityReports", "Collect compatibility reports for the export task.")
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ trait SbtVersionPolicyKeys {
final val versionPolicyFindMimaIssues = taskKey[Seq[(ModuleID, Seq[(IncompatibilityType, Problem)])]]("Binary or source compatibility issues over the previously released artifacts.")
final val versionPolicyFindIssues = taskKey[Seq[(ModuleID, (DependencyCheckReport, Seq[(IncompatibilityType, Problem)]))]]("Find both dependency issues and Mima issues.")
final val versionPolicyAssessCompatibility = taskKey[Seq[(ModuleID, Compatibility)]]("Assess the compatibility level of the project compared to its previous releases.")
final def versionPolicyExportCompatibilityReport = TaskKey[Unit]("versionPolicyExportCompatibilityReport", "Export the compatibility report into a JSON file.")
final def versionPolicyCompatibilityReportPath = SettingKey[File]("versionPolicyCompatibilityReportPath", s"Path of the compatibility report (used by ${versionPolicyExportCompatibilityReport.key.label}).")
final val versionCheck = taskKey[Unit]("Checks that the version is consistent with the intended compatibility level defined via versionPolicyIntention")

final val versionPolicyIgnored = settingKey[Seq[OrganizationArtifactName]]("Exclude these dependencies from versionPolicyReportDependencyIssues.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ object SbtVersionPolicyPlugin extends AutoPlugin {

override def globalSettings =
SbtVersionPolicySettings.reconciliationGlobalSettings ++
SbtVersionPolicySettings.schemesGlobalSettings
SbtVersionPolicySettings.schemesGlobalSettings ++
SbtVersionPolicySettings.exportGlobalSettings

override def projectSettings =
SbtVersionPolicySettings.updateSettings ++
Expand All @@ -50,25 +51,49 @@ object SbtVersionPolicyPlugin extends AutoPlugin {
// Take all the projects aggregated by this project
val aggregatedProjects = Keys.thisProject.value.aggregate

// Compute the highest compatibility level that is satisfied by all the aggregated projects
val maxCompatibility: Compatibility = Compatibility.BinaryAndSourceCompatible
aggregatedProjects.foldLeft(Def.task { maxCompatibility }) { (highestCompatibilityTask, project) =>
aggregatedCompatibility(aggregatedProjects, log) { submodule =>
Def.task {
val highestCompatibility = highestCompatibilityTask.value
val compatibilities = (project / versionPolicyAssessCompatibility).value
// The most common case is to assess the compatibility with the latest release,
// so we look at the first element only and discard the others
compatibilities.headOption match {
case Some((_, compatibility)) =>
log.debug(s"Compatibility of aggregated project ${project.project} is ${compatibility}")
(submodule / versionPolicyAssessCompatibility).value
}
} { compatibilities =>
// The most common case is to assess the compatibility with the latest release,
// so we look at the first element only and discard the others
compatibilities.headOption.map(_._2)
}.map(_._1) // Discard submodules details
}

// Compute the highest compatibility level that is satisfied by all the aggregated projects
private[sbtversionpolicy] def aggregatedCompatibility[A](
submodules: Seq[ProjectRef],
log: Logger
)(
f: ProjectRef => Def.Initialize[Task[A]]
)(
compatibility: A => Option[Compatibility]
): Def.Initialize[Task[(Compatibility, Seq[A])]] =
submodules.foldLeft(
Def.task {
(Compatibility.BinaryAndSourceCompatible: Compatibility, Seq.newBuilder[A])
}
) { case (highestCompatibilityAndResults, module) =>
Def.task {
val (highestCompatibility, results) = highestCompatibilityAndResults.value
val result = f(module).value
compatibility(result) match {
case Some(compatibility) =>
log.debug(s"Compatibility of aggregated project ${module.project} is ${compatibility}")
(
// Take the lowest of both
Compatibility.ordering.min(highestCompatibility, compatibility)
case None =>
log.debug(s"Unable to assess the compatibility level of the aggregated project ${project.project}")
highestCompatibility
}
Compatibility.ordering.min(highestCompatibility, compatibility),
results += result
)
case None =>
log.debug(s"Unable to assess the compatibility level of the aggregated project ${module.project}")
(highestCompatibility, results)
}
}
}.map { case (compatibility, builder) =>
(compatibility, builder.result())
}

}
Loading
Loading