Repository: scala-steward-org/scala-steward
Pull request #2337 (2021-11-09 20:54:28)

Support reading a default repo config from this repository
This adds support for reading a defaultl repo config from this repository. Multiple repo configs can be loaded per Scala Steward installation with the `--repo-config` command-line option and are merged into one effective global repo config. Loading the default repo config from this repository can be disabled with `--disable-default-repo-config`. This is similar to the handling of Scalafix and artifact migrations config files.

The purpose of this change is to allow library maintainers to tell Scala Steward to ignore updates until a new version is announced (#1104). They can do this adding new entries to the `updates.ignore` list of the default repo config. All (public and private) Scala Steward installations that do not opt-out of loading the default repo config will then ignore the added updates until the default repo config is changed again.
 
The default repo config also contains ignored updates that were previously removed by `FilterAlg.removeBadVersions`. So this function was just a default `updates.ignore` list in disguise.

Note also that this is a breaking change for users that are using the old `--default-repo-conf` option. `--repo-config` is a drop-in replacement for that option.


Patch:
diff --git a/build.sbt b/build.sbt
index 46ccd46c..7998600b 100644
--- a/build.sbt
+++ b/build.sbt
@@ -9,6 +9,7 @@ val rootPkg = groupId.replace("-", "")
 val gitHubOwner = "scala-steward-org"
 val gitHubUrl = s"https://github.com/$gitHubOwner/$projectName"
 val mainBranch = "master"
+val gitHubUserContent = s"https://raw.githubusercontent.com/$gitHubOwner/$projectName/$mainBranch"
 
 val moduleCrossPlatformMatrix: Map[String, List[Platform]] = Map(
   "benchmark" -> List(JVMPlatform),
@@ -142,6 +143,7 @@ lazy val core = myCrossProject("core")
       scalaBinaryVersion,
       sbtVersion,
       BuildInfoKey("gitHubUrl" -> gitHubUrl),
+      BuildInfoKey("gitHubUserContent" -> gitHubUserContent),
       BuildInfoKey("mainBranch" -> mainBranch),
       BuildInfoKey.map(git.gitHeadCommit) { case (k, v) => k -> v.getOrElse(mainBranch) },
       BuildInfoKey.map(`sbt-plugin`.jvm / moduleRootPkg) { case (_, v) =>
@@ -349,7 +351,6 @@ runSteward := Def.taskDyn {
   val args = Seq(
     Seq("--workspace", s"$projectDir/workspace"),
     Seq("--repos-file", s"$projectDir/repos.md"),
-    Seq("--default-repo-conf", s"$projectDir/default.scala-steward.conf"),
     Seq("--git-author-email", s"me@$projectName.org"),
     Seq("--vcs-login", projectName),
     Seq("--git-ask-pass", s"$home/.github/askpass/$projectName.sh"),
diff --git a/docs/help.md b/docs/help.md
index f25339f5..b58fe2ee 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -13,7 +13,6 @@ All command line arguments for the `scala-steward` application.
 
 ### Optional arguments
 ```
-  --default-repo-conf  FILE                          A .scala-steward.conf file with default values
   --git-author-name  NAME                            Git "user.name", default: "Scala Steward"
   --git-author-signing-key  KEY                      Git "user.signingKey"
   --vcs-type  VCS_TYPE                               One of "github", "gitlab", "bitbucket" or "bitbucket-server", default: "github"
@@ -28,9 +27,11 @@ All command line arguments for the `scala-steward` application.
   --env-var VAR=VALUE                                Assigns the VALUE to the environment variable VAR (can be used multiple times)
   --process-timeout  DURATION                        Timeout for external process invokations, default: "10min"
   --max-buffer-size  LINES                           Size of the buffer for the output of an external process in lines, default: "8192"
+  --repo-config  URI                                 Additional repo config file (can be used multiple times)
+  --disable-default-repo-config  BOOLEAN             Whether to disable the default repo config file
   --scalafix-migrations  URI                         Additional scalafix migrations configuraton file (can be used multiple times)
   --disable-default-scalafix-migrations  BOOLEAN     Whether to disable the default scalafix migration file
-  --artifact-migrations  FILE                        An additional file with artifact migration configurations
+  --artifact-migrations  URI                         Additional artifact migration configuration file (can be used multiple times)
   --disable-default-artifact-migrations  BOOLEAN     Whether to disable the default artifact migration file
   --cache-ttl  DURATION                              TTL for the caches, default: "2hours"
   --bitbucket-server-use-default-reviewers  BOOLEAN  Wether to assign the default reviewers to a bitbucket pull request, default: "false"
diff --git a/modules/core/src/main/resources/default.scala-steward.conf b/modules/core/src/main/resources/default.scala-steward.conf
new file mode 100644
index 00000000..e108d0f2
--- /dev/null
+++ b/modules/core/src/main/resources/default.scala-steward.conf
@@ -0,0 +1,59 @@
+updates.ignore = [
+  // Ignore the next Scala 3 version until it is announced.
+  { groupId = "org.scala-lang", artifactId = "scala3-compiler",      version = { exact = "3.1.1" } },
+  { groupId = "org.scala-lang", artifactId = "scala3-library",       version = { exact = "3.1.1" } },
+  { groupId = "org.scala-lang", artifactId = "scala3-library_sjs1",  version = { exact = "3.1.1" } },
+
+  // Ignore the next Scala 2.13 version until it is announced.
+  { groupId = "org.scala-lang", artifactId = "scala-compiler", version = { exact = "2.13.8" } },
+  { groupId = "org.scala-lang", artifactId = "scala-library",  version = { exact = "2.13.8" } },
+  { groupId = "org.scala-lang", artifactId = "scala-reflect",  version = { exact = "2.13.8" } },
+  { groupId = "org.scala-lang", artifactId = "scalap",         version = { exact = "2.13.8" } },
+
+  // Ignore the next Scala 2.12 version until it is announced.
+  { groupId = "org.scala-lang", artifactId = "scala-compiler", version = { exact = "2.12.16" } },
+  { groupId = "org.scala-lang", artifactId = "scala-library",  version = { exact = "2.12.16" } },
+  { groupId = "org.scala-lang", artifactId = "scala-reflect",  version = { exact = "2.12.16" } },
+  { groupId = "org.scala-lang", artifactId = "scalap",         version = { exact = "2.12.16" } },
+
+  { groupId = "com.google.guava", artifactId = "guava", version = "r0" },
+
+  // https://github.com/beautiful-scala/sbt-scalastyle/pull/13
+  { groupId = "com.nequissimus", artifactId = "sort-imports", version = "36845576" },
+  // https://github.com/scala-steward-org/scala-steward/issues/1413
+  { groupId = "com.nequissimus", artifactId = "sort-imports_2.12", version = "36845576" },
+
+  { groupId = "commons-beanutils", artifactId = "commons-beanutils", version = "2002" },
+  { groupId = "commons-beanutils", artifactId = "commons-beanutils", version = "2003" },
+
+  // https://github.com/scala-steward-org/scala-steward/issues/1753
+  { groupId = "commons-codec", artifactId = "commons-codec", version = "2004" },
+
+  // https://github.com/albuch/sbt-dependency-check/pull/107
+  { groupId = "commons-collections", artifactId = "commons-collections", version = "2003" },
+  // https://github.com/albuch/sbt-dependency-check/pull/85
+  { groupId = "commons-collections", artifactId = "commons-collections", version = "2004" },
+
+  // https://github.com/scala-steward-org/scala-steward/issues/1753
+  { groupId = "commons-io", artifactId = "commons-io", version = "2003" },
+
+  // https://github.com/gitbucket/gitbucket/pull/2639
+  { groupId = "commons-net", artifactId = "commons-net", version = "2003" },
+
+  // https://github.com/scala-steward-org/scala-steward/issues/105
+  { groupId = "io.monix", version = "3.0.0-fbcb270" },
+
+  // https://github.com/esamson/remder/pull/5
+  { groupId = "net.sourceforge.plantuml", artifactId = "plantuml", version = "6" },
+  { groupId = "net.sourceforge.plantuml", artifactId = "plantuml", version = "7" },
+  { groupId = "net.sourceforge.plantuml", artifactId = "plantuml", version = "8" },
+  // https://github.com/metabookmarks/sbt-plantuml-plugin/pull/21
+  // https://github.com/metabookmarks/sbt-plantuml-plugin/pull/10
+  { groupId = "net.sourceforge.plantuml", artifactId = "plantuml", version = "2017." },
+
+  // https://github.com/http4s/http4s/pull/2153
+  { groupId = "org.http4s", version = "0.19.0" },
+
+  // https://github.com/scala-js/scala-js/issues/3865
+  { groupId = "org.scala-js", version = "0.6.30" }
+]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index 6d139c93..7e0e8fe7 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -30,7 +30,6 @@ object Cli {
   final case class Args(
       workspace: File,
       reposFile: File,
-      defaultRepoConf: Option[File] = None,
       gitAuthorName: String = "Scala Steward",
       gitAuthorEmail: String,
       gitAuthorSigningKey: Option[String] = None,
@@ -48,6 +47,8 @@ object Cli {
       envVar: List[EnvVar] = Nil,
       processTimeout: FiniteDuration = 10.minutes,
       maxBufferSize: Int = 8192,
+      repoConfig: List[Uri] = Nil,
+      disableDefaultRepoConfig: Boolean = false,
       scalafixMigrations: List[Uri] = Nil,
       disableDefaultScalafixMigrations: Boolean = false,
       artifactMigrations: List[Uri] = Nil,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
index 5e45846a..6d2a1dde 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
@@ -35,11 +35,6 @@ import org.scalasteward.core.vcs.github.GitHubApp
 import scala.concurrent.duration.FiniteDuration
 
 /** Configuration for scala-steward.
-  *
-  * == [[defaultRepoConfigFile]] ==
-  * Location of default repo configuration file.
-  * This will be used if target repo doesn't have custom configuration.
-  * Note if this file doesn't exist, empty configuration will be applied
   *
   * == vcsCfg.apiHost ==
   * REST API v3 endpoints prefix
@@ -61,11 +56,11 @@ import scala.concurrent.duration.FiniteDuration
 final case class Config(
     workspace: File,
     reposFile: File,
-    defaultRepoConfigFile: Option[File],
     gitCfg: GitCfg,
     vcsCfg: VCSCfg,
     ignoreOptsFiles: Boolean,
     processCfg: ProcessCfg,
+    repoConfigCfg: RepoConfigCfg,
     scalafixCfg: ScalafixCfg,
     artifactCfg: ArtifactCfg,
     cacheTtl: FiniteDuration,
@@ -118,6 +113,11 @@ object Config {
       enableSandbox: Boolean
   )
 
+  final case class RepoConfigCfg(
+      repoConfigs: List[Uri],
+      disableDefault: Boolean
+  )
+
   final case class ScalafixCfg(
       migrations: List[Uri],
       disableDefaults: Boolean
@@ -140,7 +140,6 @@ object Config {
     Config(
       workspace = args.workspace,
       reposFile = args.reposFile,
-      defaultRepoConfigFile = args.defaultRepoConf,
       gitCfg = GitCfg(
         gitAuthor = Author(args.gitAuthorName, args.gitAuthorEmail, args.gitAuthorSigningKey),
         gitAskPass = args.gitAskPass,
@@ -163,6 +162,10 @@ object Config {
           enableSandbox = args.enableSandbox.getOrElse(!args.disableSandbox)
         )
       ),
+      repoConfigCfg = RepoConfigCfg(
+        repoConfigs = args.repoConfig,
+        disableDefault = args.disableDefaultRepoConfig
+      ),
       scalafixCfg = ScalafixCfg(
         migrations = args.scalafixMigrations,
         disableDefaults = args.disableDefaultScalafixMigrations
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index 433041ab..d0b30013 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -36,7 +36,7 @@ import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.nurture.{NurtureAlg, PullRequestRepository}
 import org.scalasteward.core.persistence.{CachingKeyValueStore, JsonKeyValueStore}
 import org.scalasteward.core.repocache._
-import org.scalasteward.core.repoconfig.RepoConfigAlg
+import org.scalasteward.core.repoconfig.{RepoConfigAlg, RepoConfigLoader}
 import org.scalasteward.core.scalafmt.ScalafmtAlg
 import org.scalasteward.core.update.artifact.{ArtifactMigrationsFinder, ArtifactMigrationsLoader}
 import org.scalasteward.core.update.{FilterAlg, PruningAlg, UpdateAlg}
@@ -107,6 +107,8 @@ object Context {
       artifactMigrationsFinder0 <- artifactMigrationsLoader0.createFinder(config.artifactCfg)
       scalafixMigrationsLoader0 = new ScalafixMigrationsLoader[F]
       scalafixMigrationsFinder0 <- scalafixMigrationsLoader0.createFinder(config.scalafixCfg)
+      repoConfigLoader0 = new RepoConfigLoader[F]
+      maybeGlobalRepoConfig <- repoConfigLoader0.loadGlobalRepoConfig(config.repoConfigCfg)
       urlChecker0 <- UrlChecker.create[F](config)
       kvsPrefix = Some(config.vcsCfg.tpe.asString)
       pullRequestsStore <- JsonKeyValueStore
@@ -125,7 +127,7 @@ object Context {
       implicit val scalafixMigrationsFinder: ScalafixMigrationsFinder = scalafixMigrationsFinder0
       implicit val urlChecker: UrlChecker[F] = urlChecker0
       implicit val dateTimeAlg: DateTimeAlg[F] = DateTimeAlg.create[F]
-      implicit val repoConfigAlg: RepoConfigAlg[F] = new RepoConfigAlg[F](config)
+      implicit val repoConfigAlg: RepoConfigAlg[F] = new RepoConfigAlg[F](maybeGlobalRepoConfig)
       implicit val filterAlg: FilterAlg[F] = new FilterAlg[F]
       implicit val gitAlg: GitAlg[F] = GenGitAlg.create[F](config.gitCfg)
       implicit val gitHubAuthAlg: GitHubAuthAlg[F] = GitHubAuthAlg.create[F]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/package.scala b/modules/core/src/main/scala/org/scalasteward/core/data/package.scala
index e309f37e..5ada41e5 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/package.scala
@@ -26,6 +26,7 @@ package object data {
       (scalaLangGroupId, ArtifactId("scala-reflect")),
       (scalaLangGroupId, ArtifactId("scalap")),
       (scalaLangGroupId, ArtifactId("scala3-compiler")),
-      (scalaLangGroupId, ArtifactId("scala3-library"))
+      (scalaLangGroupId, ArtifactId("scala3-library")),
+      (scalaLangGroupId, ArtifactId("scala3-library_sjs1"))
     )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
index 41064c9d..7f23d859 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
@@ -38,7 +38,7 @@ final class ScalafixMigrationsLoader[F[_]](implicit
       Option.unless(config.disableDefaults)(defaultScalafixMigrationsUrl)
     (maybeDefaultMigrationsUrl.toList ++ config.migrations)
       .flatTraverse(loadMigrations)
-      .flatTap(migrations => logger.info(s"Loaded ${migrations.size} Scalafix migrations"))
+      .flatTap(migrations => logger.info(s"Loaded ${migrations.size} Scalafix migration(s)"))
   }
 
   private def loadMigrations(uri: Uri): F[List[ScalafixMigration]] =
@@ -52,6 +52,6 @@ final class ScalafixMigrationsLoader[F[_]](implicit
 
 object ScalafixMigrationsLoader {
   val defaultScalafixMigrationsUrl: Uri = Uri.unsafeFromString(
-    s"https://raw.githubusercontent.com/scala-steward-org/scala-steward/${org.scalasteward.core.BuildInfo.mainBranch}/modules/core/src/main/resources/scalafix-migrations.conf"
+    s"${org.scalasteward.core.BuildInfo.gitHubUserContent}/modules/core/src/main/resources/scalafix-migrations.conf"
   )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCacheAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCacheAlg.scala
index 213e63d9..033d23fc 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCacheAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCacheAlg.scala
@@ -49,15 +49,13 @@ final class RepoCacheAlg[F[_]](config: Config)(implicit
           val latestSha1 = branchOut.commit.sha
           maybeCache
             .filter(_.sha1 === latestSha1)
-            .fold(cloneAndRefreshCache(repo, repoOut))(supplementCache(repo, _))
+            .fold(cloneAndRefreshCache(repo, repoOut))(supplementCache(repo, _).pure[F])
             .map(data => (data, repoOut))
         }
       }
 
-  private def supplementCache(repo: Repo, cache: RepoCache): F[RepoData] =
-    repoConfigAlg.mergeWithDefault(cache.maybeRepoConfig).map { config =>
-      RepoData(repo, cache, config)
-    }
+  private def supplementCache(repo: Repo, cache: RepoCache): RepoData =
+    RepoData(repo, cache, repoConfigAlg.mergeWithGlobal(cache.maybeRepoConfig))
 
   private def cloneAndRefreshCache(repo: Repo, repoOut: RepoOut): F[RepoData] =
     vcsRepoAlg.cloneAndSync(repo, repoOut) >> refreshCache(repo)
@@ -74,7 +72,7 @@ final class RepoCacheAlg[F[_]](config: Config)(implicit
       branch <- gitAlg.currentBranch(repo)
       latestSha1 <- gitAlg.latestSha1(repo, branch)
       maybeConfig <- repoConfigAlg.readRepoConfig(repo)
-      config <- repoConfigAlg.mergeWithDefault(maybeConfig)
+      config = repoConfigAlg.mergeWithGlobal(maybeConfig)
       dependencies <- buildToolDispatcher.getDependencies(repo, config)
       dependencyInfos <-
         dependencies.traverse(_.traverse(_.traverse(gatherDependencyInfo(repo, _))))
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
index 70058926..1ce6bf00 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
@@ -21,6 +21,7 @@ import cats.{Eq, Monoid}
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto._
+import io.circe.syntax._
 
 final case class RepoConfig(
     commits: CommitsConfig = CommitsConfig(),
@@ -37,6 +38,9 @@ final case class RepoConfig(
 
   def updatePullRequestsOrDefault: PullRequestUpdateStrategy =
     updatePullRequests.getOrElse(PullRequestUpdateStrategy.default)
+
+  def show: String =
+    this.asJson.spaces2
 }
 
 object RepoConfig {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
index ac511935..f0f83688 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
@@ -21,26 +21,20 @@ import cats.MonadThrow
 import cats.data.OptionT
 import cats.syntax.all._
 import io.circe.config.parser
-import org.scalasteward.core.application.Config
 import org.scalasteward.core.data.Update
 import org.scalasteward.core.io.{FileAlg, WorkspaceAlg}
 import org.scalasteward.core.repoconfig.RepoConfigAlg._
 import org.scalasteward.core.vcs.data.Repo
 import org.typelevel.log4cats.Logger
 
-final class RepoConfigAlg[F[_]](config: Config)(implicit
+final class RepoConfigAlg[F[_]](maybeGlobalRepoConfig: Option[RepoConfig])(implicit
     fileAlg: FileAlg[F],
     logger: Logger[F],
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrow[F]
 ) {
-  def mergeWithDefault(maybeRepoConfig: Option[RepoConfig]): F[RepoConfig] =
-    readDefaultRepoConfig.map { maybeDefault =>
-      (maybeRepoConfig |+| maybeDefault).getOrElse(RepoConfig.empty)
-    }
-
-  private val readDefaultRepoConfig: F[Option[RepoConfig]] =
-    config.defaultRepoConfigFile.flatTraverse(readRepoConfigFromFile(_).value)
+  def mergeWithGlobal(maybeRepoConfig: Option[RepoConfig]): RepoConfig =
+    (maybeRepoConfig |+| maybeGlobalRepoConfig).getOrElse(RepoConfig.empty)
 
   def readRepoConfig(repo: Repo): F[Option[RepoConfig]] =
     workspaceAlg
@@ -49,8 +43,10 @@ final class RepoConfigAlg[F[_]](config: Config)(implicit
 
   private def readRepoConfigFromFile(configFile: File): OptionT[F, RepoConfig] =
     OptionT(fileAlg.readFile(configFile)).map(parseRepoConfig).flatMapF {
-      case Right(repoConfig) => logger.info(s"Parsed $repoConfig").as(repoConfig.some)
-      case Left(errorMsg)    => logger.info(errorMsg).as(none[RepoConfig])
+      case Right(repoConfig) =>
+        logger.info(s"Parsed repo config ${repoConfig.show}").as(repoConfig.some)
+      case Left(errorMsg) =>
+        logger.info(errorMsg).as(none[RepoConfig])
     }
 }
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigLoader.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigLoader.scala
new file mode 100644
index 00000000..fc47716d
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigLoader.scala
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2018-2021 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.repoconfig
+
+import cats.MonadThrow
+import cats.syntax.all._
+import io.circe.config.parser.decode
+import org.http4s.Uri
+import org.scalasteward.core.application.Config.RepoConfigCfg
+import org.scalasteward.core.io.FileAlg
+import org.scalasteward.core.repoconfig.RepoConfigLoader.defaultRepoConfigUrl
+import org.typelevel.log4cats.Logger
+
+final class RepoConfigLoader[F[_]](implicit
+    fileAlg: FileAlg[F],
+    logger: Logger[F],
+    F: MonadThrow[F]
+) {
+  def loadGlobalRepoConfig(config: RepoConfigCfg): F[Option[RepoConfig]] = {
+    val maybeDefaultRepoConfigUrl =
+      Option.unless(config.disableDefault)(defaultRepoConfigUrl)
+    (maybeDefaultRepoConfigUrl.toList ++ config.repoConfigs)
+      .traverse(loadRepoConfig)
+      .flatTap(repoConfigs => logger.info(s"Loaded ${repoConfigs.size} repo config(s)"))
+      .map(_.combineAllOption)
+      .flatTap(
+        _.fold(F.unit)(config => logger.debug(s"Effective global repo config: ${config.show}"))
+      )
+  }
+
+  private def loadRepoConfig(uri: Uri): F[RepoConfig] =
+    logger.debug(s"Loading repo config from $uri") >>
+      fileAlg.readUri(uri).flatMap(decodeRepoConfig(_, uri))
+
+  private def decodeRepoConfig(content: String, uri: Uri): F[RepoConfig] =
+    F.fromEither(decode[RepoConfig](content))
+      .adaptErr(new Throwable(s"Failed to load repo config from ${uri.renderString}", _))
+}
+
+object RepoConfigLoader {
+  val defaultRepoConfigUrl: Uri = Uri.unsafeFromString(
+    s"${org.scalasteward.core.BuildInfo.gitHubUserContent}/modules/core/src/main/resources/default.scala-steward.conf"
+  )
+}
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 2e90b9c4..03bf9fb4 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
@@ -36,9 +36,15 @@ object UpdatePattern {
       filteredVersions: List[String]
   )
 
-  final case class Version(prefix: Option[String], suffix: Option[String]) {
+  final case class Version(
+      prefix: Option[String] = None,
+      suffix: Option[String] = None,
+      exact: Option[String] = None
+  ) {
     def matches(version: String): Boolean =
-      suffix.forall(version.endsWith) && prefix.forall(version.startsWith)
+      prefix.forall(version.startsWith) &&
+        suffix.forall(version.endsWith) &&
+        exact.forall(_ === version)
   }
 
   def findMatch(
@@ -62,12 +68,13 @@ object UpdatePattern {
 
   implicit val updatePatternVersionDecoder: Decoder[Version] =
     Decoder[String]
-      .map(s => Version(Some(s), None))
+      .map(s => Version(prefix = Some(s)))
       .or((hCursor: HCursor) =>
         for {
           prefix <- hCursor.downField("prefix").as[Option[String]]
           suffix <- hCursor.downField("suffix").as[Option[String]]
-        } yield Version(prefix, suffix)
+          exact <- hCursor.downField("exact").as[Option[String]]
+        } yield Version(prefix, suffix, exact)
       )
 
   implicit val eqVersion: Eq[Version] = Eq.fromUniversalEquals
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 58f0fbf8..305ba5b9 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
@@ -52,7 +52,6 @@ object FilterAlg {
         case IgnoredByConfig(_)         => "ignored by config"
         case VersionPinnedByConfig(_)   => "version is pinned by config"
         case NotAllowedByConfig(_)      => "not allowed by config"
-        case BadVersions(_)             => "bad versions"
         case NoSuitableNextVersion(_)   => "no suitable next version"
         case VersionOrderingConflict(_) => "version ordering conflict"
       }
@@ -61,7 +60,6 @@ object FilterAlg {
   final case class IgnoredByConfig(update: Update.Single) extends RejectionReason
   final case class VersionPinnedByConfig(update: Update.Single) extends RejectionReason
   final case class NotAllowedByConfig(update: Update.Single) extends RejectionReason
-  final case class BadVersions(update: Update.Single) extends RejectionReason
   final case class NoSuitableNextVersion(update: Update.Single) extends RejectionReason
   final case class VersionOrderingConflict(update: Update.Single) extends RejectionReason
 
@@ -69,9 +67,7 @@ object FilterAlg {
     repoConfig.updates.keep(update).flatMap(globalFilter)
 
   private def globalFilter(update: Update.Single): FilterResult =
-    removeBadVersions(update)
-      .flatMap(selectSuitableNextVersion)
-      .flatMap(checkVersionOrdering)
+    selectSuitableNextVersion(update).flatMap(checkVersionOrdering)
 
   def isDependencyConfigurationIgnored(dependency: Dependency): Boolean =
     dependency.configurations.fold("")(_.toLowerCase) match {
@@ -97,85 +93,4 @@ object FilterAlg {
       (coursier.core.Version(update.currentVersion), coursier.core.Version(update.nextVersion))
     if (current > next) Left(VersionOrderingConflict(update)) else Right(update)
   }
-
-  private def removeBadVersions(update: Update.Single): FilterResult =
-    update.newerVersions
-      .filterNot(badVersions(update.groupId, update.artifactId))
-      .toNel
-      .map(versions => update.copy(newerVersions = versions))
-      .fold[FilterResult](Left(BadVersions(update)))(Right.apply)
-
-  private def badVersions(groupId: GroupId, artifactId: ArtifactId): String => Boolean =
-    (groupId.value, artifactId.name) match {
-      case ("com.google.guava", "guava") =>
-        List("r03", "r05", "r06", "r07", "r08", "r09").contains
-      case ("com.nequissimus", "sort-imports") =>
-        List(
-          // https://github.com/beautiful-scala/sbt-scalastyle/pull/13
-          "36845576"
-        ).contains
-      case ("com.nequissimus", "sort-imports_2.12") =>
-        List(
-          // https://github.com/scala-steward-org/scala-steward/issues/1413
-          "36845576"
-        ).contains
-      case ("commons-beanutils", "commons-beanutils") =>
-        List(
-          "20020520",
-          "20021128.082114",
-          "20030211.134440"
-        ).contains
-      case ("commons-codec", "commons-codec") =>
-        List(
-          // https://github.com/scala-steward-org/scala-steward/issues/1753
-          "20041127.091804"
-        ).contains
-      case ("commons-collections", "commons-collections") =>
-        List(
-          "20030418.083655",
-          // https://github.com/albuch/sbt-dependency-check/pull/107
-          "20031027.000000",
-          // https://github.com/albuch/sbt-dependency-check/pull/85
-          "20040102.233541",
-          "20040616"
-        ).contains
-      case ("commons-io", "commons-io") =>
-        List(
-          // https://github.com/scala-steward-org/scala-steward/issues/1753
-          "20030203.000550"
-        ).contains
-      case ("commons-net", "commons-net") =>
-        List(
-          // https://github.com/gitbucket/gitbucket/pull/2639
-          "20030805.205232",
-          "20030623.125255",
-          "20030211.160026"
-        ).contains
-      case ("io.monix", _) =>
-        List(
-          // https://github.com/scala-steward-org/scala-steward/issues/105
-          "3.0.0-fbcb270"
-        ).contains
-      case ("net.sourceforge.plantuml", "plantuml") =>
-        s => {
-          val v = Version(s)
-          // https://github.com/esamson/remder/pull/5
-          (v >= Version("6055") && v <= Version("8059")) ||
-          // https://github.com/metabookmarks/sbt-plantuml-plugin/pull/21
-          // https://github.com/metabookmarks/sbt-plantuml-plugin/pull/10
-          (v >= Version("2017.08") && v <= Version("2017.11"))
-        }
-      case ("org.http4s", _) =>
-        List(
-          // https://github.com/http4s/http4s/pull/2153
-          "0.19.0"
-        ).contains
-      case ("org.scala-js", _) =>
-        List(
-          // https://github.com/scala-js/scala-js/issues/3865
-          "0.6.30"
-        ).contains
-      case _ =>
-        _ => false
-    }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala b/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
index 5a2a59a9..fa1663ef 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
@@ -38,7 +38,7 @@ final class ArtifactMigrationsLoader[F[_]](implicit
       Option.unless(config.disableDefaults)(defaultArtifactMigrationsUrl)
     (maybeDefaultMigrationsUrl.toList ++ config.migrations)
       .flatTraverse(loadMigrations)
-      .flatTap(migrations => logger.info(s"Loaded ${migrations.size} artifact migrations"))
+      .flatTap(migrations => logger.info(s"Loaded ${migrations.size} artifact migration(s)"))
   }
 
   private def loadMigrations(uri: Uri): F[List[ArtifactChange]] =
@@ -52,6 +52,6 @@ final class ArtifactMigrationsLoader[F[_]](implicit
 
 object ArtifactMigrationsLoader {
   val defaultArtifactMigrationsUrl: Uri = Uri.unsafeFromString(
-    s"https://raw.githubusercontent.com/scala-steward-org/scala-steward/${org.scalasteward.core.BuildInfo.mainBranch}/modules/core/src/main/resources/artifact-migrations.conf"
+    s"${org.scalasteward.core.BuildInfo.gitHubUserContent}/modules/core/src/main/resources/artifact-migrations.conf"
   )
 }


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
index 96cc8619..b6c96c3a 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
@@ -130,7 +130,7 @@ object TestInstances {
       artifactId <- Arbitrary.arbitrary[Option[String]]
       version <- Arbitrary
         .arbitrary[Option[String]]
-        .map(_.map(suffix => UpdatePattern.Version(Some(suffix), None)))
+        .map(_.map(prefix => UpdatePattern.Version(prefix = Some(prefix))))
     } yield UpdatePattern(groupId = groupId, artifactId = artifactId, version = version))
 
   private def smallListOf[A](maxSize: Int, genA: Gen[A]): Gen[List[A]] =
diff --git a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
index f33ebacf..a1dd5614 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
@@ -14,7 +14,6 @@ class CliTest extends FunSuite {
       List(
         List("--workspace", "a"),
         List("--repos-file", "b"),
-        List("--default-repo-conf", "c"),
         List("--git-author-email", "d"),
         List("--vcs-type", "gitlab"),
         List("--vcs-api-host", "http://example.com"),
@@ -27,6 +26,7 @@ class CliTest extends FunSuite {
         List("--max-buffer-size", "8192"),
         List("--scalafix-migrations", "/opt/scala-steward/extra-scalafix-migrations.conf"),
         List("--artifact-migrations", "/opt/scala-steward/extra-artifact-migrations.conf"),
+        List("--repo-config", "/opt/scala-steward/scala-steward.conf"),
         List("--github-app-id", "12345678"),
         List("--github-app-key-file", "example_app_key"),
         List("--refresh-backoff-period", "1 day")
@@ -36,7 +36,6 @@ class CliTest extends FunSuite {
       Cli.Args(
         workspace = File("a"),
         reposFile = File("b"),
-        defaultRepoConf = Some(File("c")),
         gitAuthorEmail = "d",
         vcsType = VCSType.GitLab,
         vcsApiHost = uri"http://example.com",
@@ -48,6 +47,7 @@ class CliTest extends FunSuite {
         maxBufferSize = 8192,
         scalafixMigrations = List(uri"/opt/scala-steward/extra-scalafix-migrations.conf"),
         artifactMigrations = List(uri"/opt/scala-steward/extra-artifact-migrations.conf"),
+        repoConfig = List(uri"/opt/scala-steward/scala-steward.conf"),
         githubAppId = Some(12345678),
         githubAppKeyFile = Some(File("example_app_key")),
         refreshBackoffPeriod = 1.day
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
index ba768f79..fae5bfd8 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
@@ -13,7 +13,6 @@ object MockConfig {
   val args: Cli.Args = Cli.Args(
     workspace = mockRoot / "workspace",
     reposFile = mockRoot / "repos.md",
-    defaultRepoConf = Some(mockRoot / "default.scala-steward.conf"),
     gitAuthorName = "Bot Doe",
     gitAuthorEmail = "bot@example.org",
     vcsType = VCSType.GitHub,
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
index e75f5389..4001b399 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
@@ -8,6 +8,7 @@ import org.scalasteward.core.edit.scalafix.ScalafixMigrationsLoader
 import org.scalasteward.core.io.FileAlgTest.ioFileAlg
 import org.scalasteward.core.io._
 import org.scalasteward.core.mock.MockConfig.config
+import org.scalasteward.core.repoconfig.RepoConfigLoader
 import org.scalasteward.core.update.artifact.ArtifactMigrationsLoader
 import org.typelevel.log4cats.Logger
 
@@ -19,6 +20,8 @@ object MockContext {
   implicit private val workspaceAlg: WorkspaceAlg[MockEff] = new MockWorkspaceAlg
 
   val mockState: MockState = MockState.empty.addUris(
+    RepoConfigLoader.defaultRepoConfigUrl ->
+      ioFileAlg.readResource("default.scala-steward.conf").unsafeRunSync(),
     ArtifactMigrationsLoader.defaultArtifactMigrationsUrl ->
       ioFileAlg.readResource("artifact-migrations.conf").unsafeRunSync(),
     ScalafixMigrationsLoader.defaultScalafixMigrationsUrl ->
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 c3ae9eb9..a3397bff 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
@@ -1,6 +1,7 @@
 package org.scalasteward.core.repoconfig
 
 import cats.effect.unsafe.implicits.global
+import cats.syntax.all._
 import eu.timepit.refined.types.numeric.NonNegInt
 import munit.FunSuite
 import org.scalasteward.core.TestSyntax._
@@ -13,6 +14,16 @@ import org.scalasteward.core.vcs.data.Repo
 import scala.concurrent.duration._
 
 class RepoConfigAlgTest extends FunSuite {
+  test("default config is not empty") {
+    val config = repoConfigAlg
+      .readRepoConfig(Repo("repo-config-alg", "test-1"))
+      .map(repoConfigAlg.mergeWithGlobal)
+      .runA(MockState.empty)
+      .unsafeRunSync()
+
+    assert(config =!= RepoConfig.empty)
+  }
+
   test("config with all fields set") {
     val repo = Repo("fthomas", "scala-steward")
     val configFile = MockConfig.config.workspace / "fthomas/scala-steward/.scala-steward.conf"
@@ -34,7 +45,7 @@ class RepoConfigAlgTest extends FunSuite {
     val initialState = MockState.empty.addFiles(configFile -> content).unsafeRunSync()
     val config = repoConfigAlg
       .readRepoConfig(repo)
-      .flatMap(repoConfigAlg.mergeWithDefault)
+      .map(_.getOrElse(RepoConfig.empty))
       .runA(initialState)
       .unsafeRunSync()
 
@@ -46,17 +57,17 @@ class RepoConfigAlgTest extends FunSuite {
           UpdatePattern(
             GroupId("eu.timepit"),
             Some("refined.1"),
-            Some(UpdatePattern.Version(Some("0.8."), None))
+            Some(UpdatePattern.Version(Some("0.8.")))
           ),
           UpdatePattern(
             GroupId("eu.timepit"),
             Some("refined.2"),
-            Some(UpdatePattern.Version(Some("0.8."), None))
+            Some(UpdatePattern.Version(Some("0.8.")))
           ),
           UpdatePattern(
             GroupId("eu.timepit"),
             Some("refined.3"),
-            Some(UpdatePattern.Version(None, Some("jre")))
+            Some(UpdatePattern.Version(suffix = Some("jre")))
           ),
           UpdatePattern(
             GroupId("eu.timepit"),
@@ -65,7 +76,7 @@ class RepoConfigAlgTest extends FunSuite {
           )
         ),
         ignore = List(
-          UpdatePattern(GroupId("org.acme"), None, Some(UpdatePattern.Version(Some("1.0"), None)))
+          UpdatePattern(GroupId("org.acme"), None, Some(UpdatePattern.Version(Some("1.0"))))
         ),
         limit = Some(NonNegInt.unsafeFrom(4)),
         fileExtensions = Some(List(".txt"))
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala
index 17edff76..a4802360 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala
@@ -17,9 +17,9 @@ class UpdatesConfigTest extends DisciplineSuite {
   private val ab0 = UpdatePattern(groupIdA, Some("b"), None)
   private val ac0 = UpdatePattern(groupIdA, Some("c"), None)
 
-  private val aa1 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("1"), None)))
-  private val aa2 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("2"), None)))
-  private val ac3 = UpdatePattern(groupIdA, Some("c"), Some(Version(Some("3"), None)))
+  private val aa1 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("1"))))
+  private val aa2 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("2"))))
+  private val ac3 = UpdatePattern(groupIdA, Some("c"), Some(Version(Some("3"))))
 
   private val b00 = UpdatePattern(groupIdB, None, None)
 
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 441fec44..12128df6 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
@@ -46,11 +46,6 @@ class FilterAlgTest extends FunSuite {
     )
   }
 
-  test("localFilter: update with only bad versions") {
-    val update = Single("org.http4s" % "http4s-dsl" % "0.18.0", Nel.of("0.19.0"))
-    assertEquals(localFilter(update, config), Left(BadVersions(update)))
-  }
-
   test("localFilter: update to pre-release of a different series") {
     val update = Single("com.jsuereth" % "sbt-pgp" % "1.1.2-1", Nel.of("2.0.1-M3"))
     assertEquals(localFilter(update, config), Left(NoSuitableNextVersion(update)))
@@ -74,6 +69,23 @@ class FilterAlgTest extends FunSuite {
     assertEquals(state, expected)
   }
 
+  test("ignored versions are removed") {
+    val update = Single("org.scala-lang" % "scala-compiler" % "2.13.6", Nel.of("2.13.7", "2.13.8"))
+    val config = RepoConfig(updates =
+      UpdatesConfig(ignore =
+        List(
+          UpdatePattern(
+            GroupId("org.scala-lang"),
+            Some("scala-compiler"),
+            Some(UpdatePattern.Version(exact = Some("2.13.8")))
+          )
+        )
+      )
+    )
+    val expected = Right(update.copy(newerVersions = Nel.of("2.13.7")))
+    assertEquals(localFilter(update, config), expected)
+  }
+
   test("ignore update via config updates.pin") {
     val update1 = Single("org.http4s" % "http4s-dsl" % "0.17.0", Nel.of("0.18.0"))
     val update2 = Single("eu.timepit" % "refined" % "0.8.0", Nel.of("0.8.1"))
@@ -81,11 +93,11 @@ class FilterAlgTest extends FunSuite {
     val config = RepoConfig(
       updates = UpdatesConfig(
         pin = List(
-          UpdatePattern(update1.groupId, None, Some(UpdatePattern.Version(Some("0.17"), None))),
+          UpdatePattern(update1.groupId, None, Some(UpdatePattern.Version(Some("0.17")))),
           UpdatePattern(
             update2.groupId,
             Some("refined"),
-            Some(UpdatePattern.Version(Some("0.8"), None))
+            Some(UpdatePattern.Version(Some("0.8")))
           )
         )
       )
@@ -114,7 +126,7 @@ class FilterAlgTest extends FunSuite {
     val config = RepoConfig(
       updates = UpdatesConfig(
         allow = List(
-          UpdatePattern(GroupId("org.my1"), None, Some(UpdatePattern.Version(Some("0.8"), None))),
+          UpdatePattern(GroupId("org.my1"), None, Some(UpdatePattern.Version(Some("0.8")))),
           UpdatePattern(GroupId("org.my2"), None, None),
           UpdatePattern(GroupId("org.my3"), Some("artifact"), None)
         )
@@ -141,7 +153,7 @@ class FilterAlgTest extends FunSuite {
           UpdatePattern(
             update.groupId,
             Some(update.artifactId.name),
-            Some(UpdatePattern.Version(None, Some("jre8")))
+            Some(UpdatePattern.Version(suffix = Some("jre8")))
           )
         )
       )
@@ -163,7 +175,7 @@ class FilterAlgTest extends FunSuite {
           UpdatePattern(
             update.groupId,
             Some(update.artifactId.name),
-            Some(UpdatePattern.Version(None, Some("jre11")))
+            Some(UpdatePattern.Version(suffix = Some("jre11")))
           )
         )
       )