Skip to content

Commit

Permalink
Add task versionPolicyExportCompatibilityReport to export the compa…
Browse files Browse the repository at this point in the history
…tibility reports in a machine-readable format (JSON)
  • Loading branch information
julienrf committed Dec 11, 2023
1 parent 0c28219 commit 9cc7721
Show file tree
Hide file tree
Showing 18 changed files with 375 additions and 33 deletions.
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
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ lazy val `sbt-version-policy` = project
libraryDependencies ++= Seq(
"io.get-coursier" % "interface" % "1.0.19",
"io.get-coursier" %% "versions" % "0.3.1",
"com.lihaoyi" %% "ujson" % "3.1.3", // FIXME shade
"com.eed3si9n.verify" %% "verify" % "2.0.1" % Test,
),
testFrameworks += new TestFramework("verify.runner.Framework"),
Expand Down
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())
}

}

0 comments on commit 9cc7721

Please sign in to comment.