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

Issue:
Also update scala version
It looks like scala-steward doesn't try to update the scala version.

For example in the build.sbt of metrics-scala we have: `scalaVersion := "2.12.6"` and `crossScalaVersions := Seq("2.11.12", "2.12.6")`. Version 2.12.7 is already out.

Pull request description:
Attempts a stab at #145 by introducing a flag `updates.includeScala` which when set to true (default: false) updates scala version as well

Tested this on a personal repo and it works, although needs some more work to update unit tests and docs (and of course a thorough review since I still feel new to the codebase)

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 827149695e..b834d84205 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -56,6 +56,13 @@ updates.ignore = [ { groupId = "org.acme", artifactId="foo", version = "1.0" } ]
 # Default: None
 updates.limit = 5
 
+# By default, Scala Steward does not update scala version since its tricky, error-prone
+# and results in bad PRs and/or failed builds
+# If set to true, Scala Steward will attempt to update the scala version
+# Since this feature is experimental, the default is set to false
+# Default: false
+updates.includeScala = true
+
 # If "on-conflicts", Scala Steward will update the PR it created to resolve conflicts as
 # long as you don't change it yourself.
 # If "always", Scala Steward will always update the PR it created as long as
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 476687d278..e996f7b5f0 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
@@ -34,7 +34,8 @@ final case class UpdatesConfig(
     pin: List[UpdatePattern] = List.empty,
     allow: List[UpdatePattern] = List.empty,
     ignore: List[UpdatePattern] = List.empty,
-    limit: Option[PosInt] = None
+    limit: Option[PosInt] = None,
+    includeScala: Option[Boolean] = None
 ) {
   def keep(update: Update.Single): FilterResult =
     isAllowed(update).flatMap(isPinned).flatMap(isIgnored)
@@ -76,6 +77,8 @@ object UpdatesConfig {
   implicit val updatesConfigEncoder: Encoder[UpdatesConfig] =
     deriveConfiguredEncoder
 
+  val defaultIncludeScala: Boolean = false
+
   // prevent IntelliJ from removing the import of io.circe.refined._
   locally(refinedDecoder: Decoder[PosInt])
 }
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 af0ae7284d..6cba39928e 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
@@ -73,14 +73,20 @@ object FilterAlg {
   private def localFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
     repoConfig.updates.keep(update).flatMap(globalFilter)
 
-  def isIgnoredGlobally(dependency: Dependency): Boolean =
-    ((dependency.groupId.value, dependency.artifactId.name) match {
+  def isScalaDependency(dependency: Dependency): Boolean =
+    (dependency.groupId.value, dependency.artifactId.name) match {
       case ("org.scala-lang", "scala-compiler") => true
       case ("org.scala-lang", "scala-library")  => true
       case ("org.scala-lang", "scala-reflect")  => true
       case ("org.typelevel", "scala-library")   => true
       case _                                    => false
-    }) || (dependency.configurations.fold("")(_.toLowerCase) match {
+    }
+
+  def isScalaDependencyIgnored(dependency: Dependency, ignoreScalaDependency: Boolean): Boolean =
+    ignoreScalaDependency && isScalaDependency(dependency)
+
+  def isDependencyConfigurationIgnored(dependency: Dependency): Boolean =
+    (dependency.configurations.fold("")(_.toLowerCase) match {
       case "phantom-js-jetty"    => true
       case "scalafmt"            => true
       case "scripted-sbt"        => true
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
index 714c3e40a3..fa2662188c 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
@@ -23,7 +23,7 @@ import io.chrisdavenport.log4cats.Logger
 import org.scalasteward.core.data._
 import org.scalasteward.core.nurture.PullRequestRepository
 import org.scalasteward.core.repocache.{RepoCache, RepoCacheRepository}
-import org.scalasteward.core.repoconfig.{PullRequestFrequency, RepoConfig}
+import org.scalasteward.core.repoconfig.{PullRequestFrequency, RepoConfig, UpdatesConfig}
 import org.scalasteward.core.update.PruningAlg._
 import org.scalasteward.core.update.data.UpdateState
 import org.scalasteward.core.update.data.UpdateState._
@@ -46,9 +46,16 @@ final class PruningAlg[F[_]](
     repoCacheRepository.findCache(repo).flatMap {
       case None => F.pure((false, List.empty))
       case Some(repoCache) =>
+        val ignoreScalaDependency =
+          !repoCache.maybeRepoConfig
+            .flatMap(_.updates.includeScala)
+            .getOrElse(UpdatesConfig.defaultIncludeScala)
         val dependencies = repoCache.dependencyInfos
           .flatMap(_.sequence)
-          .collect { case info if !ignoreDependency(info.value) => info.map(_.dependency) }
+          .collect {
+            case info if !ignoreDependency(info.value, ignoreScalaDependency) =>
+              info.map(_.dependency)
+          }
           .sorted
         findUpdatesNeedingAttention(repo, repoCache, dependencies)
     }
@@ -167,8 +174,10 @@ final class PruningAlg[F[_]](
 }
 
 object PruningAlg {
-  def ignoreDependency(info: DependencyInfo): Boolean =
-    info.filesContainingVersion.isEmpty || FilterAlg.isIgnoredGlobally(info.dependency)
+  def ignoreDependency(info: DependencyInfo, ignoreScalaDependency: Boolean = true): Boolean =
+    info.filesContainingVersion.isEmpty ||
+      FilterAlg.isScalaDependencyIgnored(info.dependency, ignoreScalaDependency) ||
+      FilterAlg.isDependencyConfigurationIgnored(info.dependency)
 
   def collectOutdatedDependencies(updateStates: List[UpdateState]): List[DependencyOutdated] =
     updateStates.collect { case state: DependencyOutdated => state }


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 44d6599403..71e7051dfe 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
@@ -25,6 +25,7 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
          |               ]
          |updates.ignore = [ { groupId = "org.acme", version = "1.0" } ]
          |updates.limit = 4
+         |updates.includeScala = true
          |pullRequests.frequency = "@weekly"
          |commits.message = "Update ${artifactName} from ${currentVersion} to ${nextVersion}"
          |""".stripMargin
@@ -60,7 +61,8 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
         ignore = List(
           UpdatePattern(GroupId("org.acme"), None, Some(UpdatePattern.Version(Some("1.0"), None)))
         ),
-        limit = Some(PosInt.unsafeFrom(4))
+        limit = Some(PosInt.unsafeFrom(4)),
+        includeScala = Some(true)
       ),
       commits = CommitsConfig(
         message = Some("Update ${artifactName} from ${currentVersion} to ${nextVersion}")
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 9cdd4932f6..756e5ca98f 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
@@ -2,7 +2,7 @@ package org.scalasteward.core.update
 
 import cats.implicits._
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.GroupId
+import org.scalasteward.core.data.{ArtifactId, Dependency, GroupId}
 import org.scalasteward.core.data.Update.Single
 import org.scalasteward.core.mock.MockContext.filterAlg
 import org.scalasteward.core.mock.MockState
@@ -206,4 +206,49 @@ class FilterAlgTest extends AnyFunSuite with Matchers {
 
     filtered shouldBe List()
   }
+
+  test("isScalaDependency: true") {
+    val dependency = Dependency(
+      GroupId("org.scala-lang"),
+      ArtifactId("scala-compiler", "scala-compiler_2.12"),
+      "2.12.10"
+    )
+    FilterAlg.isScalaDependency(dependency) shouldBe true
+  }
+
+  test("isScalaDependency: false") {
+    val dependency =
+      Dependency(GroupId("org.typelevel"), ArtifactId("cats-effect", "cats-effect_2.12"), "1.0.0")
+    FilterAlg.isScalaDependency(dependency) shouldBe false
+  }
+
+  test("isScalaDependencyIgnored: true") {
+    val dependency = Dependency(
+      GroupId("org.scala-lang"),
+      ArtifactId("scala-compiler", "scala-compiler_2.12"),
+      "2.12.10"
+    )
+    FilterAlg.isScalaDependencyIgnored(dependency, ignoreScalaDependency = true) shouldBe true
+  }
+
+  test("isScalaDependencyIgnored: false") {
+    val dependency = Dependency(
+      GroupId("org.scala-lang"),
+      ArtifactId("scala-compiler", "scala-compiler_2.12"),
+      "2.12.10"
+    )
+    FilterAlg.isScalaDependencyIgnored(dependency, ignoreScalaDependency = false) shouldBe false
+  }
+
+  test("isDependencyConfigurationIgnored: false") {
+    val dependency =
+      Dependency(GroupId("org.typelevel"), ArtifactId("cats-effect", "cats-effect_2.12"), "1.0.0")
+    FilterAlg.isDependencyConfigurationIgnored(dependency.copy(configurations = Some("foo"))) shouldBe false
+  }
+
+  test("isDependencyConfigurationIgnored: true") {
+    val dependency =
+      Dependency(GroupId("org.typelevel"), ArtifactId("cats-effect", "cats-effect_2.12"), "1.0.0")
+    FilterAlg.isDependencyConfigurationIgnored(dependency.copy(configurations = Some("scalafmt"))) shouldBe true
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
index c900f4a660..fc0e94d6a6 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
@@ -69,4 +69,235 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
       )
     )
   }
+
+  test("needsAttention: 0 updates when includeScala not specified in repo config") {
+    val repo = Repo("fthomas", "scalafix-test")
+    val repoCacheFile =
+      config.workspace / "store/repo_cache/v1/fthomas/scalafix-test/repo_cache.json"
+    val repoCacheContent =
+      s"""|{
+          |  "sha1": "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |  "dependencyInfos" : [
+          |    {
+          |      "value" : [
+          |        {
+          |          "dependency" : {
+          |            "groupId" : "org.scala-lang",
+          |            "artifactId" : {
+          |              "name" : "scala-library",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.12.10",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          },
+          |          "filesContainingVersion" : [
+          |            "build.sbt"
+          |          ]
+          |        }
+          |      ],
+          |      "resolvers" : [
+          |        {
+          |          "MavenRepository" : {
+          |            "name" : "public",
+          |            "location" : "https://foobar.org/maven2/"
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  ],
+          |  "maybeRepoConfig": {
+          |    "pullRequests": {
+          |      "frequency": "@daily"
+          |    }
+          |  }
+          |}""".stripMargin
+    val pullRequestsFile =
+      config.workspace / "store/pull_requests/v1/fthomas/scalafix-test/pull_requests.json"
+    val pullRequestsContent =
+      s"""|{
+          |  "https://github.com/fthomas/scalafix-test/pull/27" : {
+          |    "baseSha1" : "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |    "update" : {
+          |      "Single" : {
+          |        "crossDependency" : [
+          |          {
+          |            "groupId" : "org.scalatest",
+          |            "artifactId" : {
+          |              "name" : "scalatest",
+          |              "maybeCrossName" : "scalatest_2.12"
+          |            },
+          |            "version" : "3.0.8",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          }
+          |        ],
+          |        "newerVersions" : [
+          |          "3.1.0"
+          |        ],
+          |        "newerGroupId" : null
+          |      }
+          |    },
+          |    "state" : "open",
+          |    "entryCreatedAt" : 1581969227183
+          |  }
+          |}""".stripMargin
+    val versionsFile =
+      config.workspace / "store/versions/v1/https/foobar.org/maven2/org/scala-lang/scala-library/versions.json"
+    val versionsContent =
+      s"""|{
+          |  "updatedAt" : 9999999999999,
+          |  "versions" : [
+          |    "2.12.9",
+          |    "2.12.10",
+          |    "2.12.11",
+          |    "2.13.0",
+          |    "2.13.1"
+          |  ]
+          |}
+          |""".stripMargin
+    val initial = MockState.empty
+      .add(repoCacheFile, repoCacheContent)
+      .add(pullRequestsFile, pullRequestsContent)
+      .add(versionsFile, versionsContent)
+    val state = pruningAlg.needsAttention(repo).runS(initial).unsafeRunSync()
+
+    state shouldBe initial.copy(
+      commands = Vector(
+        List("read", repoCacheFile.toString),
+        List("read", pullRequestsFile.toString)
+      ),
+      logs = Vector(
+        (None, "Find updates for fthomas/scalafix-test"),
+        (None, "Found 0 updates"),
+        (None, "fthomas/scalafix-test is up-to-date")
+      )
+    )
+  }
+
+  test("needsAttention: update scala-library when includeScala=true in repo config") {
+    val repo = Repo("fthomas", "scalafix-test")
+    val repoCacheFile =
+      config.workspace / "store/repo_cache/v1/fthomas/scalafix-test/repo_cache.json"
+    val repoCacheContent =
+      s"""|{
+          |  "sha1": "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |  "dependencyInfos" : [
+          |    {
+          |      "value" : [
+          |        {
+          |          "dependency" : {
+          |            "groupId" : "org.scala-lang",
+          |            "artifactId" : {
+          |              "name" : "scala-library",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.12.10",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          },
+          |          "filesContainingVersion" : [
+          |            "build.sbt"
+          |          ]
+          |        }
+          |      ],
+          |      "resolvers" : [
+          |        {
+          |          "MavenRepository" : {
+          |            "name" : "public",
+          |            "location" : "https://foobar.org/maven2/"
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  ],
+          |  "maybeRepoConfig": {
+          |    "pullRequests": {
+          |      "frequency": "@daily"
+          |    },
+          |    "updates" : {
+          |      "pin" : [
+          |      ],
+          |      "allow" : [
+          |      ],
+          |      "ignore" : [
+          |      ],
+          |      "limit" : null,
+          |      "includeScala" : true
+          |    }
+          |  }
+          |}""".stripMargin
+    val pullRequestsFile =
+      config.workspace / "store/pull_requests/v1/fthomas/scalafix-test/pull_requests.json"
+    val pullRequestsContent =
+      s"""|{
+          |  "https://github.com/fthomas/scalafix-test/pull/27" : {
+          |    "baseSha1" : "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |    "update" : {
+          |      "Single" : {
+          |        "crossDependency" : [
+          |          {
+          |            "groupId" : "org.scalatest",
+          |            "artifactId" : {
+          |              "name" : "scalatest",
+          |              "maybeCrossName" : "scalatest_2.12"
+          |            },
+          |            "version" : "3.0.8",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          }
+          |        ],
+          |        "newerVersions" : [
+          |          "3.1.0"
+          |        ],
+          |        "newerGroupId" : null
+          |      }
+          |    },
+          |    "state" : "open",
+          |    "entryCreatedAt" : 1581969227183
+          |  }
+          |}""".stripMargin
+    val versionsFile =
+      config.workspace / "store/versions/v1/https/foobar.org/maven2/org/scala-lang/scala-library/versions.json"
+    val versionsContent =
+      s"""|{
+          |  "updatedAt" : 9999999999999,
+          |  "versions" : [
+          |    "2.12.9",
+          |    "2.12.10",
+          |    "2.12.11",
+          |    "2.13.0",
+          |    "2.13.1"
+          |  ]
+          |}
+          |""".stripMargin
+    val initial = MockState.empty
+      .add(repoCacheFile, repoCacheContent)
+      .add(pullRequestsFile, pullRequestsContent)
+      .add(versionsFile, versionsContent)
+    val state = pruningAlg.needsAttention(repo).runS(initial).unsafeRunSync()
+
+    state shouldBe initial.copy(
+      commands = Vector(
+        List("read", repoCacheFile.toString),
+        List("read", versionsFile.toString),
+        List("read", pullRequestsFile.toString),
+        List("read", versionsFile.toString),
+        List("read", pullRequestsFile.toString),
+        List("read", pullRequestsFile.toString)
+      ),
+      logs = Vector(
+        (None, "Find updates for fthomas/scalafix-test"),
+        (None, "Found 1 update:\n  org.scala-lang:scala-library : 2.12.10 -> 2.12.11"),
+        (
+          None,
+          "fthomas/scalafix-test is outdated:\n  DependencyOutdated(CrossDependency(NonEmptyList(Dependency(org.scala-lang,ArtifactId(scala-library,None),2.12.10,None,None,None))),Single(CrossDependency(NonEmptyList(Dependency(org.scala-lang,ArtifactId(scala-library,None),2.12.10,None,None,None))),NonEmptyList(2.12.11),None))"
+        )
+      )
+    )
+  }
 }