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

Issue:
Add --disable-default-artifact-migrations flag to prevent needing external connection
The existing flag:

```
  --disable-default-scalafix-migrations  BOOLEAN     Wether to disable the default scalafix migration file
```

prevents a call out to raw.githubusercontent.com as part of Scala Steward running. I'd like to add the equivalent for artifact migrations:

```
  --disable-default-artifact-migrations  BOOLEAN     Whether to disable the default artifact migrations file
```

This would allow scala-steward to run inside environments where all external network access needs to be whitelisted, without having to whitelist raw.githubusercontent.com

Pull request description:
What this PR does
===========

Fixes #2273 

Adds a new `--disable-default-artifact-migrations` boolean flag to the Scala Steward CLI which, when enabled, disables the fetch of the default `artifact-migrations.conf` from raw.githubusercontent.com

Why this change?
=============

This will allow Scala Steward to be run in environments where outbound network traffic is tightly controlled or denied without whitelisting raw.githubusercontent.com

Patch:
diff --git a/docs/help.md b/docs/help.md
index 86dff335e2..04b8c206db 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -29,8 +29,9 @@ All command line arguments for the `scala-steward` application.
   --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"
   --scalafix-migrations  URI                         Additional scalafix migrations configuraton file (can be used multiple times)
-  --disable-default-scalafix-migrations  BOOLEAN     Wether to disable the default scalafix migration file
+  --disable-default-scalafix-migrations  BOOLEAN     Whether to disable the default scalafix migration file
   --artifact-migrations  FILE                        An additional file with artifact migration configurations
+  --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"
   --gitlab-merge-when-pipeline-succeeds  BOOLEAN     Wether to merge a gitlab merge request when the pipeline succeeds
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 5c7d8df0be..4b4e64d1d2 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
@@ -52,6 +52,7 @@ object Cli {
       scalafixMigrations: List[Uri] = Nil,
       disableDefaultScalafixMigrations: Boolean = false,
       artifactMigrations: List[Uri] = Nil,
+      disableDefaultArtifactMigrations: Boolean = false,
       cacheTtl: FiniteDuration = 2.hours,
       bitbucketServerUseDefaultReviewers: Boolean = false,
       gitlabMergeWhenPipelineSucceeds: Boolean = false,
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 7cbe56e9a6..3c9b22d014 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
@@ -71,7 +71,7 @@ final case class Config(
     ignoreOptsFiles: Boolean,
     processCfg: ProcessCfg,
     scalafixCfg: ScalafixCfg,
-    artifactMigrations: List[Uri],
+    artifactCfg: ArtifactCfg,
     cacheTtl: FiniteDuration,
     bitbucketServerCfg: BitbucketServerCfg,
     gitLabCfg: GitLabCfg,
@@ -119,6 +119,11 @@ object Config {
       disableDefaults: Boolean
   )
 
+  final case class ArtifactCfg(
+      migrations: List[Uri],
+      disableDefaults: Boolean
+  )
+
   final case class BitbucketServerCfg(
       useDefaultReviewers: Boolean
   )
@@ -156,7 +161,10 @@ object Config {
         migrations = args.scalafixMigrations,
         disableDefaults = args.disableDefaultScalafixMigrations
       ),
-      artifactMigrations = args.artifactMigrations,
+      artifactCfg = ArtifactCfg(
+        migrations = args.artifactMigrations,
+        disableDefaults = args.disableDefaultArtifactMigrations
+      ),
       cacheTtl = args.cacheTtl,
       bitbucketServerCfg = BitbucketServerCfg(
         useDefaultReviewers = args.bitbucketServerUseDefaultReviewers
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 d99e6f9f39..fed536f5e9 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
@@ -63,6 +63,7 @@ final class Context[F[_]](implicit
     val pullRequestRepository: PullRequestRepository[F],
     val repoConfigAlg: RepoConfigAlg[F],
     val sbtAlg: SbtAlg[F],
+    val artifactMigrationsLoader: ArtifactMigrationsLoader[F],
     val scalafixMigrationsFinder: ScalafixMigrationsFinder,
     val scalafixMigrationsLoader: ScalafixMigrationsLoader[F],
     val scalafmtAlg: ScalafmtAlg[F],
@@ -98,7 +99,7 @@ object Context {
       _ <- printBanner[F]
       vcsUser <- config.vcsUser[F]
       artifactMigrationsLoader0 = new ArtifactMigrationsLoader[F]
-      artifactMigrationsFinder0 <- artifactMigrationsLoader0.createFinder(config.artifactMigrations)
+      artifactMigrationsFinder0 <- artifactMigrationsLoader0.createFinder(config.artifactCfg)
       scalafixMigrationsLoader0 = new ScalafixMigrationsLoader[F]
       scalafixMigrationsFinder0 <- scalafixMigrationsLoader0.createFinder(config.scalafixCfg)
       urlChecker0 <- UrlChecker.create[F](config)
@@ -113,6 +114,7 @@ object Context {
       versionsStore <- JsonKeyValueStore
         .create[F, VersionsCache.Key, VersionsCache.Value]("versions", "2")
     } yield {
+      implicit val artifactMigrationsLoader: ArtifactMigrationsLoader[F] = artifactMigrationsLoader0
       implicit val artifactMigrationsFinder: ArtifactMigrationsFinder = artifactMigrationsFinder0
       implicit val scalafixMigrationsLoader: ScalafixMigrationsLoader[F] = scalafixMigrationsLoader0
       implicit val scalafixMigrationsFinder: ScalafixMigrationsFinder = scalafixMigrationsFinder0
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 a40859424e..057ff47290 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
@@ -21,6 +21,7 @@ import cats.syntax.all._
 import io.circe.config.parser.decode
 import org.http4s.Uri
 import org.http4s.implicits.http4sLiteralsSyntax
+import org.scalasteward.core.application.Config.ArtifactCfg
 import org.scalasteward.core.io.FileAlg
 import org.scalasteward.core.update.artifact.ArtifactMigrationsLoader.defaultArtifactMigrationsUrl
 import org.typelevel.log4cats.Logger
@@ -30,13 +31,16 @@ final class ArtifactMigrationsLoader[F[_]](implicit
     logger: Logger[F],
     F: MonadThrow[F]
 ) {
-  def createFinder(artifactMigrations: List[Uri]): F[ArtifactMigrationsFinder] =
-    loadAll(artifactMigrations).map(new ArtifactMigrationsFinder(_))
+  def createFinder(config: ArtifactCfg): F[ArtifactMigrationsFinder] =
+    loadAll(config).map(new ArtifactMigrationsFinder(_))
 
-  def loadAll(artifactMigrations: List[Uri]): F[List[ArtifactChange]] =
-    (defaultArtifactMigrationsUrl :: artifactMigrations)
+  def loadAll(config: ArtifactCfg): F[List[ArtifactChange]] = {
+    val maybeDefaultMigrationsUrl =
+      Option.unless(config.disableDefaults)(defaultArtifactMigrationsUrl)
+    (maybeDefaultMigrationsUrl.toList ++ config.migrations)
       .flatTraverse(loadMigrations)
       .flatTap(migrations => logger.info(s"Loaded ${migrations.size} artifact migrations"))
+  }
 
   private def loadMigrations(uri: Uri): F[List[ArtifactChange]] =
     logger.debug(s"Loading artifact migrations from $uri") >>


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoaderTest.scala b/modules/core/src/test/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoaderTest.scala
new file mode 100644
index 0000000000..ac11aaa8fa
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoaderTest.scala
@@ -0,0 +1,75 @@
+package org.scalasteward.core.update.artifact
+
+import cats.effect.unsafe.implicits.global
+import munit.FunSuite
+import org.http4s.Uri
+import org.scalasteward.core.application.Config.ArtifactCfg
+import org.scalasteward.core.data.GroupId
+import org.scalasteward.core.mock.MockConfig.mockRoot
+import org.scalasteward.core.mock.MockContext.context.artifactMigrationsLoader
+import org.scalasteward.core.mock.MockContext.mockState
+
+class ArtifactMigrationsLoaderTest extends FunSuite {
+  val migrationsUri: Uri = Uri.unsafeFromString(s"$mockRoot/extra-migrations.conf")
+  val migrationsContent: String =
+    """|changes = [
+       |  {
+       |    groupIdBefore = com.evilcorp
+       |    groupIdAfter = org.ice.cream
+       |    artifactIdAfter = yumyum
+       |    initialVersion = 2.0.0
+       |  }
+       |]""".stripMargin
+  val migration: ArtifactChange = ArtifactChange(
+    groupIdBefore = Some(GroupId("com.evilcorp")),
+    groupIdAfter = GroupId("org.ice.cream"),
+    artifactIdBefore = None,
+    artifactIdAfter = "yumyum",
+    initialVersion = "2.0.0"
+  )
+
+  test("loadAll: without extra file, without defaults") {
+    val migrations = artifactMigrationsLoader
+      .loadAll(ArtifactCfg(Nil, disableDefaults = true))
+      .runA(mockState)
+      .unsafeRunSync()
+    assertEquals(migrations.size, 0)
+  }
+
+  test("loadAll: without extra file, with defaults") {
+    val migrations = artifactMigrationsLoader
+      .loadAll(ArtifactCfg(Nil, disableDefaults = false))
+      .runA(mockState)
+      .unsafeRunSync()
+    assert(clue(migrations.size) > 0)
+  }
+
+  test("loadAll: with extra file, without defaults") {
+    val initialState = mockState.addUris(migrationsUri -> migrationsContent)
+    val migrations = artifactMigrationsLoader
+      .loadAll(ArtifactCfg(List(migrationsUri), disableDefaults = true))
+      .runA(initialState)
+      .unsafeRunSync()
+    assertEquals(migrations, List(migration))
+  }
+
+  test("loadAll: with extra file, with defaults") {
+    val initialState = mockState.addUris(migrationsUri -> migrationsContent)
+    val migrations = artifactMigrationsLoader
+      .loadAll(ArtifactCfg(List(migrationsUri), disableDefaults = false))
+      .runA(initialState)
+      .unsafeRunSync()
+    assert(clue(migrations.size) > 1)
+    assert(clue(migrations).contains(migration))
+  }
+
+  test("loadAll: malformed extra file") {
+    val initialState = mockState.addUris(migrationsUri -> """{"key": "i'm not a valid Migration}""")
+    val migrations = artifactMigrationsLoader
+      .loadAll(ArtifactCfg(List(migrationsUri), disableDefaults = false))
+      .runA(initialState)
+      .attempt
+      .unsafeRunSync()
+    assert(migrations.isLeft)
+  }
+}