Repository: scala-steward-org/scala-steward (scala)

Issue:
Allow pre-release updates to configured dependencies
It could be that one wants to update the specific artifact to a pre-released version ignoring same series limitation of the current implementation.

Proposed solution is to have it configured separately, with the list being empty by default (disallow all).

Pull request description:
Fixes #2634 

Added dedicated configuration, which alters pre-release version checks for specified dependencies.

Similar features has been mentioned there https://github.com/scala-steward-org/scala-steward/issues/1023#issuecomment-551942307

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 9485cd22ab..3e3b805109 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -52,6 +52,13 @@ updates.pin  = [ { groupId = "com.example", artifactId="foo", version = "1.1." }
 # Defaults to empty `[]` which mean Scala Steward will not ignore dependencies.
 updates.ignore = [ { groupId = "org.acme", artifactId="foo", version = "1.0" } ]
 
+# The dependencies which match the given patterns are allowed to be updated to pre-release from stable.
+# This also implies, that it will be allowed for snapshot versions to be updated to snapshots of different series.
+#
+# Each pattern must have `groupId`, and may have `artifactId` and `version`.
+# Defaults to empty `[]` which mean Scala Steward will ignore all stable to pre-release update options.
+updates.allowPreReleases  = [ { groupId = "com.example", artifactId="foo" } ]
+
 # If set, Scala Steward will only create or update `n` PRs each time it runs (see `pullRequests.frequency` above).
 # Useful if running frequently and/or CI build are costly
 # Default: None
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala b/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
index 6b6dd0c16b..19bdaaaa21 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
@@ -37,7 +37,7 @@ final case class Version(value: String) {
     * Implements the scheme described in this FAQ:
     * https://github.com/scala-steward-org/scala-steward/blob/main/docs/faq.md#how-does-scala-steward-decide-what-version-it-is-updating-to
     */
-  def selectNext(versions: List[Version]): Option[Version] = {
+  def selectNext(versions: List[Version], allowPreReleases: Boolean = false): Option[Version] = {
     val cutoff = alnumComponentsWithoutPreRelease.length - 1
     val newerVersionsByCommonPrefix =
       versions
@@ -50,21 +50,33 @@ final case class Version(value: String) {
       .sortBy { case (commonPrefix, _) => commonPrefix.length }
       .flatMap { case (commonPrefix, vs) =>
         val sameSeries = cutoff === commonPrefix.length
-        vs.filterNot { v =>
+
+        val preReleasesFilter: Version => Boolean = v =>
+          // Do not select snapshots if we are not already on snapshot
+          v.isSnapshot && !isSnapshot
+
+        val releasesFilter: Version => Boolean = v =>
           // Do not select pre-releases of different series.
           (v.isPreRelease && !sameSeries) ||
-          // Do not select pre-releases of the same series if this is not a pre-release.
-          (v.isPreRelease && !isPreRelease && sameSeries) ||
-          // Do not select versions with pre-release identifiers whose order is smaller
-          // than the order of possible pre-release identifiers in this version. This,
-          // for example, prevents updates from 2.1.4.0-RC17 to 2.1.4.0-RC17+1-307f2f6c-SNAPSHOT.
-          v.minAlphaOrder < minAlphaOrder ||
+            // Do not select pre-releases of the same series if this is not a pre-release.
+            (v.isPreRelease && !isPreRelease && sameSeries) ||
+            // Do not select versions with pre-release identifiers whose order is smaller
+            // than the order of possible pre-release identifiers in this version. This,
+            // for example, prevents updates from 2.1.4.0-RC17 to 2.1.4.0-RC17+1-307f2f6c-SNAPSHOT.
+            (v.minAlphaOrder < minAlphaOrder) ||
+            // Do not select a version with hash if this version contains no hash.
+            (v.containsHash && !containsHash)
+
+        val commonFilter: Version => Boolean = v =>
           // Do not select versions that are identical up to the hashes.
           v.alnumComponents === alnumComponents ||
-          // Do not select a version with hash if this version contains no hash.
-          (v.containsHash && !containsHash) ||
-          // Don't select "versions" like %5BWARNING%5D.
-          !v.startsWithLetterOrDigit
+            // Don't select "versions" like %5BWARNING%5D.
+            !v.startsWithLetterOrDigit
+
+        vs.filterNot { v =>
+          commonFilter(v) ||
+          (!allowPreReleases && releasesFilter(v)) ||
+          (allowPreReleases && preReleasesFilter(v))
         }.sorted
       }
       .lastOption
@@ -84,6 +96,13 @@ final case class Version(value: String) {
       case _                          => false
     }
 
+  private def isSnapshot: Boolean =
+    components.exists {
+      case a: Version.Component.Alpha => a.isSnapshotIdent
+      case _: Version.Component.Hash  => true
+      case _                          => false
+    }
+
   private def containsHash: Boolean =
     components.exists {
       case _: Version.Component.Hash => true
@@ -139,6 +158,7 @@ object Version {
     }
     final case class Alpha(value: String) extends Component {
       def isPreReleaseIdent: Boolean = order < 0
+      def isSnapshotIdent: Boolean = order <= -6
       def order: Int =
         value.toUpperCase match {
           case "SNAP" | "SNAPSHOT" | "NIGHTLY" => -6
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
index 4a5a27a66d..ebbd67fec5 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
@@ -35,6 +35,7 @@ import org.scalasteward.core.util.{combineOptions, Nel}
 final case class UpdatesConfig(
     pin: List[UpdatePattern] = List.empty,
     allow: List[UpdatePattern] = List.empty,
+    allowPreReleases: List[UpdatePattern] = List.empty,
     ignore: List[UpdatePattern] = List.empty,
     limit: Option[NonNegInt] = None,
     fileExtensions: Option[List[String]] = None
@@ -45,6 +46,16 @@ final case class UpdatesConfig(
   def keep(update: Update.Single): FilterResult =
     isAllowed(update).flatMap(isPinned).flatMap(isIgnored)
 
+  def preRelease(update: Update.Single): FilterResult =
+    isAllowedPreReleases(update)
+
+  private def isAllowedPreReleases(update: Update.Single): FilterResult = {
+    val m = UpdatePattern.findMatch(allowPreReleases, update, include = true)
+    if (m.filteredVersions.nonEmpty)
+      Right(update)
+    else Left(NotAllowedByConfig(update))
+  }
+
   private def isAllowed(update: Update.Single): FilterResult = {
     val m = UpdatePattern.findMatch(allow, update, include = true)
     if (m.filteredVersions.nonEmpty)
@@ -92,6 +103,7 @@ object UpdatesConfig {
         UpdatesConfig(
           pin = mergePin(x.pin, y.pin),
           allow = mergeAllow(x.allow, y.allow),
+          allowPreReleases = mergeAllow(x.allowPreReleases, y.allowPreReleases),
           ignore = mergeIgnore(x.ignore, y.ignore),
           limit = x.limit.orElse(y.limit),
           fileExtensions = mergeFileExtensions(x.fileExtensions, y.fileExtensions)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
index d2b4892599..cb9665d603 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
@@ -64,10 +64,10 @@ object FilterAlg {
   final case class VersionOrderingConflict(update: Update.Single) extends RejectionReason
 
   def localFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
-    repoConfig.updates.keep(update).flatMap(globalFilter)
+    repoConfig.updates.keep(update).flatMap(globalFilter(_, repoConfig))
 
-  private def globalFilter(update: Update.Single): FilterResult =
-    selectSuitableNextVersion(update).flatMap(checkVersionOrdering)
+  private def globalFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
+    selectSuitableNextVersion(update, repoConfig).flatMap(checkVersionOrdering)
 
   def isDependencyConfigurationIgnored(dependency: Dependency): Boolean =
     dependency.configurations.fold("")(_.toLowerCase) match {
@@ -79,9 +79,15 @@ object FilterAlg {
       case _                     => false
     }
 
-  private def selectSuitableNextVersion(update: Update.Single): FilterResult = {
+  private def selectSuitableNextVersion(
+      update: Update.Single,
+      repoConfig: RepoConfig
+  ): FilterResult = {
     val newerVersions = update.newerVersions.toList
-    val maybeNext = update.currentVersion.selectNext(newerVersions)
+    val maybeNext = repoConfig.updates.preRelease(update) match {
+      case Left(_)  => update.currentVersion.selectNext(newerVersions)
+      case Right(_) => update.currentVersion.selectNext(newerVersions, allowPreReleases = true)
+    }
     maybeNext match {
       case Some(next) => Right(update.copy(newerVersions = Nel.of(next)))
       case None       => Left(NoSuitableNextVersion(update))


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
index 64f4a2c2b8..5f2f09b3aa 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
@@ -220,7 +220,12 @@ class VersionTest extends DisciplineSuite {
       ("1.4.12", List("1032048a", "1032048a4c2", "7d2bf0af+20171218-1522"), None),
       ("1.1", List("20000000"), None),
       ("10000000", List("20000000"), Some("20000000")),
-      ("1032048a", List("2032048a4c2"), Some("2032048a4c2"))
+      ("1032048a", List("2032048a4c2"), Some("2032048a4c2")),
+      ("0.1.1-3dfde9d7", List("0.2.1-485fdf3b"), None),
+      ("0.1.1", List("0.2.1-485fdf3b"), None),
+      ("0.1.1-ALPHA", List("0.2.1-485fdf3b"), None),
+      ("0.1.1-ALPHA", List("0.2.1-BETA"), None),
+      ("0.1.1", List("0.2.1-BETA"), None)
     )
 
     val rnd = new Random()
@@ -230,6 +235,48 @@ class VersionTest extends DisciplineSuite {
     }
   }
 
+  test("selectNext, table PreReleases") {
+    val nextVersions = List(
+      ("0.1.1-3dfde9d7", List("0.2.1-485fdf3b"), Some("0.2.1-485fdf3b")),
+      (
+        "0.21.6+75-6ad94f6f-SNAPSHOT",
+        List("0.21.6+99-a0087dd-SNAPSHOT"),
+        Some("0.21.6+99-a0087dd-SNAPSHOT")
+      ),
+      (
+        "0.21.6+75-6ad94f6f-SNAPSHOT",
+        List("0.21.7+99-a0087dd-SNAPSHOT"),
+        Some("0.21.7+99-a0087dd-SNAPSHOT")
+      ),
+      (
+        "0.21.6+75-6ad94f6f-SNAPSHOT",
+        List("0.22.0+99-a0087dd-SNAPSHOT"),
+        Some("0.22.0+99-a0087dd-SNAPSHOT")
+      ),
+      ("0.1.1", List("0.2.1-485fdf3b"), None),
+      ("0.1.1-RC1", List("0.2.1-485fdf3b"), None),
+      ("0.1.1-ALPHA", List("0.2.1-SNAPSHOT"), None),
+      ("0.21.5", List("0.21.6+75-6ad94f6f-SNAPSHOT"), None),
+      ("0.21.6", List("0.21.6+75-6ad94f6f-SNAPSHOT"), None),
+      ("0.21.7", List("0.21.6+75-6ad94f6f-SNAPSHOT"), None),
+      ("0.1.1-RC1", List("0.1.1-RC2"), Some("0.1.1-RC2")),
+      ("0.1.1-RC1", List("0.1.2-RC1"), Some("0.1.2-RC1")),
+      ("0.1.1", List("0.2.1-BETA"), Some("0.2.1-BETA")),
+      ("0.1.1", List("0.2.0-M1"), Some("0.2.0-M1")),
+      ("0.1.1-ALPHA", List("0.2.1-BETA"), Some("0.2.1-BETA")),
+      ("0.1.1-3dfde9d7", List("0.1.2"), Some("0.1.2")),
+      ("0.1.1", List("0.1.2"), Some("0.1.2")),
+      ("0.1.1-RC1", List("0.1.2"), Some("0.1.2")),
+      ("0.1.1-ALPHA", List("0.1.2"), Some("0.1.2"))
+    )
+
+    val rnd = new Random()
+    nextVersions.foreach { case (current, versions, result) =>
+      val obtained = Version(current).selectNext(rnd.shuffle(versions).map(Version.apply), true)
+      assertEquals(obtained, result.map(Version.apply))
+    }
+  }
+
   test("Component: round-trip") {
     forAll { str: String => assertEquals(Component.render(Component.parse(str)), str) }
   }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
index 2d5ba9ebb7..1b4f7b7b6e 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
@@ -29,7 +29,7 @@ class RepoConfigAlgTest extends FunSuite {
     val repo = Repo("fthomas", "scala-steward")
     val configFile = MockConfig.config.workspace / "fthomas/scala-steward/.scala-steward.conf"
     val content =
-      """|updates.allow  = [ { groupId = "eu.timepit"} ]
+      """|updates.allow  = [ { groupId = "eu.timepit" } ]
          |updates.pin  = [
          |                 { groupId = "eu.timepit", artifactId = "refined.1", version = "0.8." },
          |                 { groupId = "eu.timepit", artifactId = "refined.2", version = { prefix="0.8." } },
@@ -37,6 +37,7 @@ class RepoConfigAlgTest extends FunSuite {
          |                 { groupId = "eu.timepit", artifactId = "refined.4", version = { prefix="0.8.", suffix="jre" } }
          |               ]
          |updates.ignore = [ { groupId = "org.acme", version = "1.0" } ]
+         |updates.allowPreReleases = [ { groupId = "eu.timepit" } ]
          |updates.limit = 4
          |updates.fileExtensions = [ ".txt" ]
          |pullRequests.frequency = "@weekly"
@@ -74,6 +75,7 @@ class RepoConfigAlgTest extends FunSuite {
           )
         ),
         ignore = List(UpdatePattern("org.acme".g, None, Some(VersionPattern(Some("1.0"))))),
+        allowPreReleases = List(UpdatePattern("eu.timepit".g, None, None)),
         limit = Some(NonNegInt.unsafeFrom(4)),
         fileExtensions = Some(List(".txt"))
       ),
diff --git a/modules/core/src/test/scala/org/scalasteward/core/update/FilterAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/update/FilterAlgTest.scala
index bca9e282ae..61a45630d8 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/update/FilterAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/update/FilterAlgTest.scala
@@ -45,11 +45,22 @@ class FilterAlgTest extends FunSuite {
     )
   }
 
-  test("localFilter: update to pre-release of a different series") {
+  test("localFilter: update to pre-releases of a different series") {
     val update = ("com.jsuereth".g % "sbt-pgp".a % "1.1.2-1" %> Nel.of("2.0.1-M3")).single
     assertEquals(localFilter(update, config), Left(NoSuitableNextVersion(update)))
   }
 
+  test("localFilter: allowed update to pre-releases of a different series") {
+    val update = ("com.jsuereth".g % "sbt-pgp".a % "1.1.2-1" %> Nel.of("2.0.1-M3")).single
+    val allowedPreReleases =
+      UpdatePattern("com.jsuereth".g, Some("sbt-pgp"), None) :: config.updates.allowPreReleases
+    val configWithAllowed =
+      config.copy(updates = config.updates.copy(allowPreReleases = allowedPreReleases))
+
+    val expected = Right(update.copy(newerVersions = Nel.of("2.0.1-M3".v)))
+    assertEquals(localFilter(update, configWithAllowed), expected)
+  }
+
   test("ignore update via config updates.ignore") {
     val update1 = ("org.http4s".g % "http4s-dsl".a % "0.17.0" %> "0.18.0").single
     val update2 = ("eu.timepit".g % "refined".a % "0.8.0" %> "0.8.1").single