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

Issue:
Update only specified dependency (cli argument for updates.allow?)
## What

Could Scala Steward create pull-requests for only a specific dependency (across multiple downstream repositories)?

## Why?

If a dependency (in our case, an internal private library) is updated, we'd like to get create pull requests for this library (and only that) in downstream consumers of this library.

## What I've tried:

We use Scala Steward through the [scala-steward-action](https://github.com/scala-steward-org/scala-steward-action), so it would be helpful to be able to pass this as a CLI argument.

Just setting [`updates.allow` in .scala-steward.conf](https://github.com/fthomas/scala-steward/blob/master/docs/repo-specific-configuration.md) wouldn't help since Scala Steward checks out the repos itself, where I can't modify that file locally per runthrough.

## Notes

I understand that changing this would be a bit of a shift in which purpose Scala Steward fulfills, 
from: 
`update all dependencies in this/these repos` (consumer-centric)
into:
`update this dependency in all my consumers` (producer/dependency-centric)

Curious to hear your thoughts on this.

Pull request description:
In some scenarios there is a need to limit dependencies you want to update for all repos. 

Currently you'd need to update repo configuration (`.scala-steward.conf`) for every project you are working on.
If the repo doesn't have configuration (`maybeRepoConfig`) it will fallback to empty `RepoConfig.default`.

This PR is adding ability to override fallback to `RepoConfig.default` by reading `default.scala-steward.conf` within main scala steward repo. It gives you all the features as described in [repo-specific-configuration.md](https://github.com/fthomas/scala-steward/blob/master/docs/repo-specific-configuration.md)

Specifically, you can limit dependencies by providing `allow` settings (which is helpful if you have 100+ repos and you want to propagate specific dependencies, only)

### Changes
- Added `--default-repo-config` setting (default to `default.scala-steward.conf`)
- Chain of configuration: check repoConf (`$repo/.scala-steward.conf`) => fallback to DefaultRepoConf (`root/default.scala-steward.conf`) => fallback to empty configuration
- Added extra tests

Patch:
diff --git a/build.sbt b/build.sbt
index fc282f9984..78138ab3b0 100644
--- a/build.sbt
+++ b/build.sbt
@@ -236,6 +236,7 @@ runSteward := Def.taskDyn {
   val args = Seq(
     Seq("--workspace", s"$projectDir/workspace"),
     Seq("--repos-file", s"$projectDir/repos.md"),
+    Seq("--default-repo-config", 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/running.md b/docs/running.md
index 61b89ebbb9..c7895bd8f4 100644
--- a/docs/running.md
+++ b/docs/running.md
@@ -6,6 +6,7 @@ sbt stage
 ./modules/core/.jvm/target/universal/stage/bin/scala-steward \
   --workspace  "$STEWARD_DIR/workspace" \
   --repos-file "$STEWARD_DIR/repos.md" \
+  --default-repo-conf "$STEWARD_DIR/default.scala-steward.conf" \
   --git-author-email ${EMAIL} \
   --vcs-api-host "https://api.github.com" \
   --vcs-login ${LOGIN} \
@@ -24,6 +25,7 @@ sbt docker:publishLocal
 docker run -v $STEWARD_DIR:/opt/scala-steward -it fthomas/scala-steward:latest \
   --workspace  "/opt/scala-steward/workspace" \
   --repos-file "/opt/scala-steward/repos.md" \
+  --default-repo-conf "/opt/scala-steward/default.scala-steward.conf" \
   --git-author-email ${EMAIL} \
   --vcs-api-host "https://api.github.com" \
   --vcs-login ${LOGIN} \
@@ -71,7 +73,7 @@ The file should contain a single line: `credentials += Credentials("Some Nexus R
 ```
 sbt
 project core
-run --disable-sandbox --do-not-fork --workspace "/path/workspace" --repos-file "/path/repos.md" --git-ask-pass "/path/pass.sh" --git-author-email "email@example.org" --vcs-type "gitlab" --vcs-api-host "https://gitlab.com/api/v4/" --vcs-login "gitlab.steward"
+run --disable-sandbox --do-not-fork --workspace "/path/workspace" --repos-file "/path/repos.md" --default-repo-conf "/path/default.scala-steward.conf" --git-ask-pass "/path/pass.sh" --git-author-email "email@example.org" --vcs-type "gitlab" --vcs-api-host "https://gitlab.com/api/v4/" --vcs-login "gitlab.steward"
 ```
 
 
@@ -93,6 +95,7 @@ docker run -v $PWD:/opt/scala-steward \
     --do-not-fork \
     --workspace "/opt/scala-steward/workspace" \
     --repos-file "/opt/scala-steward/repos.md" \
+    --default-repo-conf "/opt/scala-steward/default.scala-steward.conf" \
     --git-ask-pass "/opt/scala-steward/pass.sh" \
     --git-author-email "myemail@company.xyz" \
     --vcs-type "bitbucket" \
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 89a892a8da..50f72ea9d2 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
@@ -37,6 +37,7 @@ object Cli {
   final case class Args(
       workspace: String,
       reposFile: String,
+      defaultRepoConf: String = "default.scala-steward.conf",
       gitAuthorName: String = "Scala Steward",
       gitAuthorEmail: String,
       vcsType: SupportedVCS = SupportedVCS.GitHub,
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 c97eaa8951..d1b104b505 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
@@ -28,6 +28,11 @@ import scala.concurrent.duration.FiniteDuration
 import scala.sys.process.Process
 
 /** 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
   *
   * == [[vcsApiHost]] ==
   * REST API v3 endpoints prefix
@@ -49,6 +54,7 @@ import scala.sys.process.Process
 final case class Config(
     workspace: File,
     reposFile: File,
+    defaultRepoConfigFile: File,
     gitAuthor: Author,
     vcsType: SupportedVCS,
     vcsApiHost: Uri,
@@ -84,6 +90,7 @@ object Config {
       Config(
         workspace = args.workspace.toFile,
         reposFile = args.reposFile.toFile,
+        defaultRepoConfigFile = args.defaultRepoConf.toFile,
         gitAuthor = Author(args.gitAuthorName, args.gitAuthorEmail),
         vcsType = args.vcsType,
         vcsApiHost = args.vcsApiHost,
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 74ea1acd42..7b8d100b6e 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
@@ -31,7 +31,7 @@ final case class RepoConfig(
 }
 
 object RepoConfig {
-  val default: RepoConfig = RepoConfig()
+  private[repoconfig] val empty: RepoConfig = RepoConfig()
 
   implicit val customConfig: Configuration =
     Configuration.default.withDefaults
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 3faa29dcf6..3e383efe12 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
@@ -16,10 +16,12 @@
 
 package org.scalasteward.core.repoconfig
 
+import better.files.File
 import cats.data.OptionT
 import cats.implicits._
 import io.chrisdavenport.log4cats.Logger
 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._
@@ -27,23 +29,36 @@ import org.scalasteward.core.util.MonadThrowable
 import org.scalasteward.core.vcs.data.Repo
 
 final class RepoConfigAlg[F[_]](implicit
+    config: Config,
     fileAlg: FileAlg[F],
     logger: Logger[F],
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrowable[F]
 ) {
   def readRepoConfigOrDefault(repo: Repo): F[RepoConfig] =
-    readRepoConfig(repo).map(_.getOrElse(RepoConfig.default))
+    readRepoConfig(repo).flatMap { config =>
+      config.map(F.pure).getOrElse(defaultRepoConfig)
+    }
+
+  /**
+    * Default configuration will try to read file specified in config.reposDefaultConfig first;
+    * if not found - fallback to empty configuration.
+    */
+  lazy val defaultRepoConfig: F[RepoConfig] =
+    readRepoConfigFromFile(config.defaultRepoConfigFile).map(_.getOrElse(RepoConfig.empty))
 
   def readRepoConfig(repo: Repo): F[Option[RepoConfig]] =
-    workspaceAlg.repoDir(repo).flatMap { dir =>
-      val configFile = dir / repoConfigBasename
-      val maybeRepoConfig = OptionT(fileAlg.readFile(configFile)).map(parseRepoConfig).flatMapF {
-        case Right(config)  => logger.info(s"Parsed $config").as(config.some)
-        case Left(errorMsg) => logger.info(errorMsg).as(none[RepoConfig])
-      }
-      maybeRepoConfig.value
+    workspaceAlg
+      .repoDir(repo)
+      .flatMap(dir => readRepoConfigFromFile(dir / repoConfigBasename))
+
+  private def readRepoConfigFromFile(configFile: File): F[Option[RepoConfig]] = {
+    val maybeRepoConfig = OptionT(fileAlg.readFile(configFile)).map(parseRepoConfig).flatMapF {
+      case Right(config)  => logger.info(s"Parsed $config").as(config.some)
+      case Left(errorMsg) => logger.info(errorMsg).as(none[RepoConfig])
     }
+    maybeRepoConfig.value
+  }
 }
 
 object RepoConfigAlg {
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 3e91827df7..8aa41e2fd7 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,12 @@ 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, UpdatesConfig}
+import org.scalasteward.core.repoconfig.{
+  PullRequestFrequency,
+  RepoConfig,
+  RepoConfigAlg,
+  UpdatesConfig
+}
 import org.scalasteward.core.update.PruningAlg._
 import org.scalasteward.core.update.data.UpdateState
 import org.scalasteward.core.update.data.UpdateState._
@@ -38,6 +43,7 @@ final class PruningAlg[F[_]](implicit
     logger: Logger[F],
     pullRequestRepository: PullRequestRepository[F],
     repoCacheRepository: RepoCacheRepository[F],
+    repoConfigAlg: RepoConfigAlg[F],
     updateAlg: UpdateAlg[F],
     F: Monad[F]
 ) {
@@ -64,10 +70,12 @@ final class PruningAlg[F[_]](implicit
       repoCache: RepoCache,
       dependencies: List[Scope.Dependency]
   ): F[(Boolean, List[Update.Single])] = {
-    val repoConfig = repoCache.maybeRepoConfig.getOrElse(RepoConfig.default)
+
     val depsWithoutResolvers = dependencies.map(_.value).distinct
     for {
       _ <- logger.info(s"Find updates for ${repo.show}")
+      repoConfig <-
+        OptionT.fromOption[F](repoCache.maybeRepoConfig).getOrElseF(repoConfigAlg.defaultRepoConfig)
       updates0 <- updateAlg.findUpdates(dependencies, repoConfig, None)
       updateStates0 <- findAllUpdateStates(repo, repoCache, depsWithoutResolvers, updates0)
       outdatedDeps = collectOutdatedDependencies(updateStates0)
diff --git a/scripts/run.sh b/scripts/run.sh
index dfa361d82f..45d23320b1 100755
--- a/scripts/run.sh
+++ b/scripts/run.sh
@@ -21,6 +21,7 @@ curl -s -o "$REPOS_GITLAB" https://raw.githubusercontent.com/scala-steward-org/r
 
 LOGIN="scala-steward"
 
+# Used for running public Scala Steward instance, repos-file / repos-default-conf will be set later
 COMMON_ARGS=(
   --workspace "$STEWARD_DIR/workspace"
   --git-author-email "me@$LOGIN.org"


Test patch:
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 c38d1bc753..b8864a7c3c 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
@@ -16,6 +16,7 @@ class CliTest extends AnyFunSuite with Matchers {
       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"),
@@ -30,6 +31,7 @@ class CliTest extends AnyFunSuite with Matchers {
       Cli.Args(
         workspace = "a",
         reposFile = "b",
+        defaultRepoConf = "c",
         gitAuthorEmail = "d",
         vcsType = SupportedVCS.Gitlab,
         vcsApiHost = uri"http://example.com",
@@ -42,6 +44,30 @@ class CliTest extends AnyFunSuite with Matchers {
     )
   }
 
+  test("parseArgs minimal version") {
+    cli.parseArgs(
+      List(
+        List("--workspace", "a"),
+        List("--repos-file", "b"),
+        List("--git-author-email", "d"),
+        List("--vcs-login", "e"),
+        List("--git-ask-pass", "f")
+      ).flatten
+    ) shouldBe Right(
+      Cli.Args(
+        workspace = "a",
+        reposFile = "b",
+        gitAuthorEmail = "d",
+        vcsLogin = "e",
+        gitAskPass = "f"
+      )
+    )
+  }
+
+  test("parseArgs fail if required option not provided") {
+    cli.parseArgs(Nil).isLeft shouldBe true
+  }
+
   test("env-var without equals sign") {
     Cli.envVarArgParser(None, "SBT_OPTS").isLeft shouldBe true
   }
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 aa4c3086c6..5d4340d99d 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
@@ -31,6 +31,7 @@ object MockContext {
   implicit val config: Config = Config(
     workspace = File.temp / "ws",
     reposFile = File.temp / "repos.md",
+    defaultRepoConfigFile = File.temp / "default.scala-steward.conf",
     gitAuthor = Author("Bot Doe", "bot@example.org"),
     vcsType = SupportedVCS.GitHub,
     vcsApiHost = Uri(),