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

Issue:
Target JRE as part of artifact name
In one of my projects I can see update of `"com.microsoft.sqlserver" % "mssql-jdbc"`
from `7.2.2.jre8` to `7.2.2.jre11`, where it should stay at `jre8`.
https://mvnrepository.com/artifact/com.microsoft.sqlserver/mssql-jdbc

I also know such JRE versioning exist for other projects like Guava.
https://mvnrepository.com/artifact/com.google.guava/guava

Maybe this case could be handled by Steward?

Pull request description:
This PR implements the idea proposed by @fthomas in a [comment](https://github.com/fthomas/scala-steward/issues/1038#issuecomment-543178540) to issue #1038:

> `UpdatePattern`, which is used for `.scala-steward.conf`, allows to match versions but only via `startsWith`. In this case we want to match a version if it ends with `jre8`. I would propose to change the version matching code to `endsWith` when `UpdatePattern.version` starts with `.*`.

After finishing this I realised one shortcoming: it is not possible to restrict  for the same artefact the prefix and the suffix. E.g pin a major version and enforce `jre8`. One solution would be to use `.*` as a separator between prefix and suffix, e.g. `7..*jre8` (there are 2 dots so it matches only against version 7 and not against version 70)

fixes #1038

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index d06a836748..698eefc475 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -15,7 +15,7 @@ updates.allow  = [ { groupId = "com.example" } ]
 # Each pattern must have `groupId`, `version` and optional `artifactId`.
 # Defaults to empty `[]` which mean Scala Steward will update all dependencies.
 # the following example will allow to update foo when version is 1.1.x
-updates.pin  = [ { groupId = "com.example", artifactId="foo", version = "1.1" } ]
+updates.pin  = [ { groupId = "com.example", artifactId="foo", version = "1.1." } ]
 
 # The dependencies which match the given pattern are NOT updated.
 #
@@ -37,6 +37,17 @@ updates.limit = 5
 updatePullRequests = "always" | "on-conflicts" | "never"
 ```
 
+The version information given in the patterns above can be in two formats:
+1. just a `version` field that is treated as a prefix of the version
+2. a structure consisting of `prefix` and / or `suffix` that are matched against the beginning or the end of the version
+
+```properties
+version = "1.1."
+version = { prefix = "1.1." }
+version = { suffix = "jre8" }
+version = { prefix = "1.1.", suffix = "jre8" }
+```
+
 ## Ignore lines
 
 Though `updates.ignores` offers granular configuration to exclude dependencies from update, Scala Steward also recognizes markers in file to ignore lines.
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
index 95607d61dd..670c79e96b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
@@ -18,26 +18,37 @@ package org.scalasteward.core.repoconfig
 
 import cats.implicits._
 import io.circe.generic.semiauto._
-import io.circe.{Decoder, Encoder}
+import io.circe.{Decoder, Encoder, HCursor}
 import org.scalasteward.core.data.{GroupId, Update}
 
 final case class UpdatePattern(
     groupId: GroupId,
     artifactId: Option[String],
-    version: Option[String]
+    version: Option[UpdatePattern.Version]
 )
 
 object UpdatePattern {
   final case class MatchResult(
       byArtifactId: List[UpdatePattern],
-      byVersion: List[UpdatePattern]
+      filteredVersions: List[String]
   )
 
-  def findMatch(patterns: List[UpdatePattern], update: Update.Single): MatchResult = {
+  final case class Version(prefix: Option[String], suffix: Option[String]) {
+    def matches(version: String): Boolean =
+      suffix.forall(version.endsWith) && prefix.forall(version.startsWith)
+  }
+
+  def findMatch(
+      patterns: List[UpdatePattern],
+      update: Update.Single,
+      include: Boolean
+  ): MatchResult = {
     val byGroupId = patterns.filter(_.groupId === update.groupId)
     val byArtifactId = byGroupId.filter(_.artifactId.forall(_ === update.artifactId.name))
-    val byVersion = byArtifactId.filter(_.version.forall(update.nextVersion.startsWith))
-    MatchResult(byArtifactId, byVersion)
+    val filteredVersions = update.newerVersions.filter(newVersion =>
+      byArtifactId.exists(_.version.forall(_.matches(newVersion))) === include
+    )
+    MatchResult(byArtifactId, filteredVersions)
   }
 
   implicit val updatePatternDecoder: Decoder[UpdatePattern] =
@@ -45,4 +56,17 @@ object UpdatePattern {
 
   implicit val updatePatternEncoder: Encoder[UpdatePattern] =
     deriveEncoder
+
+  implicit val updatePatternVersionDecoder: Decoder[Version] =
+    Decoder[String]
+      .map(s => Version(Some(s), None))
+      .or((hCursor: HCursor) =>
+        for {
+          prefix <- hCursor.downField("prefix").as[Option[String]]
+          suffix <- hCursor.downField("suffix").as[Option[String]]
+        } yield Version(prefix, suffix)
+      )
+
+  implicit val updatePatternVersionEncoder: Encoder[Version] =
+    deriveEncoder
 }
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 7f12d38f18..9a34c57e7d 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
@@ -16,7 +16,6 @@
 
 package org.scalasteward.core.repoconfig
 
-import cats.implicits._
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto._
 import io.circe.{Decoder, Encoder}
@@ -27,6 +26,7 @@ import org.scalasteward.core.update.FilterAlg.{
   NotAllowedByConfig,
   VersionPinnedByConfig
 }
+import org.scalasteward.core.util.Nel
 
 final case class UpdatesConfig(
     pin: List[UpdatePattern] = List.empty,
@@ -35,26 +35,32 @@ final case class UpdatesConfig(
     limit: Option[Int] = None
 ) {
   def keep(update: Update.Single): FilterResult =
-    isAllowed(update) *> isPinned(update) *> isIgnored(update)
+    isAllowed(update).flatMap(isPinned).flatMap(isIgnored)
 
   private def isAllowed(update: Update.Single): FilterResult = {
-    lazy val matched = UpdatePattern.findMatch(allow, update)
-    val isIncluded = allow.isEmpty || matched.byVersion.nonEmpty
-    Either.cond(isIncluded, update, NotAllowedByConfig(update))
+    val m = UpdatePattern.findMatch(allow, update, include = true)
+    if (m.filteredVersions.nonEmpty) {
+      Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
+    } else if (allow.isEmpty)
+      Right(update)
+    else Left(NotAllowedByConfig(update))
   }
 
   private def isPinned(update: Update.Single): FilterResult = {
-    val m = UpdatePattern.findMatch(pin, update)
-    Either.cond(
-      m.byArtifactId.isEmpty || m.byVersion.nonEmpty,
-      update,
-      VersionPinnedByConfig(update)
-    )
+    val m = UpdatePattern.findMatch(pin, update, include = true)
+    if (m.filteredVersions.nonEmpty) {
+      Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
+    } else if (m.byArtifactId.isEmpty)
+      Right(update)
+    else Left(VersionPinnedByConfig(update))
   }
 
   private def isIgnored(update: Update.Single): FilterResult = {
-    val m = UpdatePattern.findMatch(ignore, update)
-    if (m.byVersion.nonEmpty) Left(IgnoredByConfig(update)) else Right(update)
+    val m = UpdatePattern.findMatch(ignore, update, include = false)
+    if (m.filteredVersions.nonEmpty)
+      Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
+    else
+      Left(IgnoredByConfig(update))
   }
 }
 
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 4353b95c9e..d781c6e8f6 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
@@ -71,7 +71,7 @@ object FilterAlg {
       .flatMap(checkVersionOrdering)
 
   private def localFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
-    globalFilter(update).flatMap(repoConfig.updates.keep)
+    repoConfig.updates.keep(update).flatMap(globalFilter)
 
   def isIgnoredGlobally(dependency: Dependency): Boolean =
     ((dependency.groupId.value, dependency.artifactId.name) match {
@@ -148,6 +148,7 @@ object FilterAlg {
           // https://github.com/scala-js/scala-js/issues/3865
           "0.6.30"
         ).contains
-      case _ => _ => false
+      case _ =>
+        _ => false
     }
 }


Test patch:
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 a65512882c..1dccf5c675 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
@@ -16,7 +16,12 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
     val configFile = File.temp / "ws/fthomas/scala-steward/.scala-steward.conf"
     val content =
       """|updates.allow  = [ { groupId = "eu.timepit"} ]
-         |updates.pin  = [ { groupId = "eu.timepit", artifactId = "refined", version = "0.8." } ]
+         |updates.pin  = [
+         |                 { groupId = "eu.timepit", artifactId = "refined.1", version = "0.8." },
+         |                 { groupId = "eu.timepit", artifactId = "refined.2", version = { prefix="0.8." } },
+         |                 { groupId = "eu.timepit", artifactId = "refined.3", version = { suffix="jre" } },
+         |                 { groupId = "eu.timepit", artifactId = "refined.4", version = { prefix="0.8.", suffix="jre" } }
+         |               ]
          |updates.ignore = [ { groupId = "org.acme", version = "1.0" } ]
          |updates.limit = 4
          |""".stripMargin
@@ -26,8 +31,31 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
     config shouldBe RepoConfig(
       updates = UpdatesConfig(
         allow = List(UpdatePattern(GroupId("eu.timepit"), None, None)),
-        pin = List(UpdatePattern(GroupId("eu.timepit"), Some("refined"), Some("0.8."))),
-        ignore = List(UpdatePattern(GroupId("org.acme"), None, Some("1.0"))),
+        pin = List(
+          UpdatePattern(
+            GroupId("eu.timepit"),
+            Some("refined.1"),
+            Some(UpdatePattern.Version(Some("0.8."), None))
+          ),
+          UpdatePattern(
+            GroupId("eu.timepit"),
+            Some("refined.2"),
+            Some(UpdatePattern.Version(Some("0.8."), None))
+          ),
+          UpdatePattern(
+            GroupId("eu.timepit"),
+            Some("refined.3"),
+            Some(UpdatePattern.Version(None, Some("jre")))
+          ),
+          UpdatePattern(
+            GroupId("eu.timepit"),
+            Some("refined.4"),
+            Some(UpdatePattern.Version(Some("0.8."), Some("jre")))
+          )
+        ),
+        ignore = List(
+          UpdatePattern(GroupId("org.acme"), None, Some(UpdatePattern.Version(Some("1.0"), None)))
+        ),
         limit = Some(4)
       )
     )
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 d20d33a466..092eb38cd8 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
@@ -78,8 +78,12 @@ class FilterAlgTest extends AnyFunSuite with Matchers {
     val config = RepoConfig(
       updates = UpdatesConfig(
         pin = List(
-          UpdatePattern(update1.groupId, None, Some("0.17")),
-          UpdatePattern(update2.groupId, Some("refined"), Some("0.8"))
+          UpdatePattern(update1.groupId, None, Some(UpdatePattern.Version(Some("0.17"), None))),
+          UpdatePattern(
+            update2.groupId,
+            Some("refined"),
+            Some(UpdatePattern.Version(Some("0.8"), None))
+          )
         )
       )
     )
@@ -107,7 +111,7 @@ class FilterAlgTest extends AnyFunSuite with Matchers {
     val config = RepoConfig(
       updates = UpdatesConfig(
         allow = List(
-          UpdatePattern(GroupId("org.my1"), None, Some("0.8")),
+          UpdatePattern(GroupId("org.my1"), None, Some(UpdatePattern.Version(Some("0.8"), None))),
           UpdatePattern(GroupId("org.my2"), None, None),
           UpdatePattern(GroupId("org.my3"), Some("artifact"), None)
         )
@@ -121,4 +125,85 @@ class FilterAlgTest extends AnyFunSuite with Matchers {
 
     filtered shouldBe included
   }
+
+  test("ignore update via config updates.pin using suffix") {
+    val update =
+      Single(
+        "com.microsoft.sqlserver" % "mssql-jdbc" % "7.2.2.jre8",
+        Nel.of("7.2.2.jre11", "7.3.0.jre8", "7.3.0.jre11")
+      )
+
+    val config = RepoConfig(
+      updates = UpdatesConfig(
+        pin = List(
+          UpdatePattern(
+            update.groupId,
+            Some(update.artifactId.name),
+            Some(UpdatePattern.Version(None, Some("jre8")))
+          )
+        )
+      )
+    )
+
+    val filtered = filterAlg
+      .localFilterMany(config, List(update))
+      .runA(MockState.empty)
+      .unsafeRunSync()
+
+    filtered shouldBe List(update.copy(newerVersions = Nel.of("7.3.0.jre8")))
+  }
+
+  test("ignore update via config updates.ignore using suffix") {
+    val update =
+      Single(
+        "com.microsoft.sqlserver" % "mssql-jdbc" % "7.2.2.jre8",
+        Nel.of("7.2.2.jre11", "7.3.0.jre8", "7.3.0.jre11")
+      )
+
+    val config = RepoConfig(
+      updates = UpdatesConfig(
+        ignore = List(
+          UpdatePattern(
+            update.groupId,
+            Some(update.artifactId.name),
+            Some(UpdatePattern.Version(None, Some("jre11")))
+          )
+        )
+      )
+    )
+
+    val filtered = filterAlg
+      .localFilterMany(config, List(update))
+      .runA(MockState.empty)
+      .unsafeRunSync()
+
+    filtered shouldBe List(update.copy(newerVersions = Nel.of("7.3.0.jre8")))
+  }
+
+  test("ignore update via config updates.pin using prefix and suffix") {
+    val update =
+      Single(
+        "com.microsoft.sqlserver" % "mssql-jdbc" % "7.2.2.jre8",
+        Nel.of("7.2.2.jre11", "7.3.0.jre8", "7.3.0.jre11")
+      )
+
+    val config = RepoConfig(
+      updates = UpdatesConfig(
+        pin = List(
+          UpdatePattern(
+            update.groupId,
+            Some(update.artifactId.name),
+            Some(UpdatePattern.Version(Some("7.2."), Some("jre8")))
+          )
+        )
+      )
+    )
+
+    val filtered = filterAlg
+      .localFilterMany(config, List(update))
+      .runA(MockState.empty)
+      .unsafeRunSync()
+
+    filtered shouldBe List()
+  }
 }