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

Issue:
Extract credentials from sbt and pass them to Coursier
Some package repositories require credentials for checking which versions of an artifact are available but we currently ignore credentials when extracting information from sbt. To support finding updates in private package repositories that require credentials for read access, we need to include credentials in `StewardPlugin`'s `stewardDependencies` task, add them to [`Scope`](https://github.com/fthomas/scala-steward/blob/83b85f1edad64e8ad7a024e556f9f89cb0ffbb23/modules/core/src/main/scala/org/scalasteward/core/data/Scope.scala), and pass them to Coursier in `CoursierAlg`.

sbt has two types of credentials, [`DirectCredentials`](https://www.scala-sbt.org/1.x/api/sbt/librarymanagement/ivy/DirectCredentials.html) and [`FileCredentials`](https://www.scala-sbt.org/1.x/api/sbt/librarymanagement/ivy/FileCredentials.html) and I think we should also support both.

Pull request:
This PR fixes #1291

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
index 6e9e8d4c3f..92aed8b20d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
@@ -20,11 +20,12 @@ import cats.effect._
 import cats.implicits._
 import cats.{Applicative, Parallel}
 import coursier.cache.{CachePolicy, FileCache}
-import coursier.core.Project
+import coursier.core.{Authentication, Project}
 import coursier.interop.cats._
 import coursier.{Fetch, Info, Module, ModuleName, Organization}
 import io.chrisdavenport.log4cats.Logger
 import org.http4s.Uri
+import org.scalasteward.core.data.Resolver.Credentials
 import org.scalasteward.core.data.{Dependency, Resolver, Scope, Version}
 
 /** An interface to [[https://get-coursier.io Coursier]] used for
@@ -125,12 +126,16 @@ object CoursierAlg {
 
   private def toCoursierRepository(resolver: Resolver): Either[String, coursier.Repository] =
     resolver match {
-      case Resolver.MavenRepository(_, location) =>
-        Right(coursier.maven.MavenRepository.apply(location))
-      case Resolver.IvyRepository(_, pattern) =>
-        coursier.ivy.IvyRepository.parse(pattern)
+      case Resolver.MavenRepository(_, location, creds) =>
+        Right(coursier.maven.MavenRepository.apply(location, creds.map(toCoursierAuthentication)))
+      case Resolver.IvyRepository(_, pattern, creds) =>
+        coursier.ivy.IvyRepository
+          .parse(pattern, authentication = creds.map(toCoursierAuthentication))
     }
 
+  private def toCoursierAuthentication(credentials: Credentials): Authentication =
+    Authentication(credentials.user, credentials.pass)
+
   private def getParentDependency(project: Project): Option[coursier.Dependency] =
     project.parent.map {
       case (module, version) =>
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/Resolver.scala b/modules/core/src/main/scala/org/scalasteward/core/data/Resolver.scala
index 65af38a0b4..02d506006f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/Resolver.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/Resolver.scala
@@ -25,26 +25,33 @@ import org.scalasteward.core.data.Resolver._
 sealed trait Resolver extends Product with Serializable {
   val path: String = {
     val url = this match {
-      case MavenRepository(_, location) => location
-      case IvyRepository(_, pattern)    => pattern.takeWhile(!Set('[', '(')(_))
+      case MavenRepository(_, location, _) => location
+      case IvyRepository(_, pattern, _)    => pattern.takeWhile(!Set('[', '(')(_))
     }
     url.replace(":", "")
   }
 }
 
 object Resolver {
-  final case class MavenRepository(name: String, location: String) extends Resolver
-  final case class IvyRepository(name: String, pattern: String) extends Resolver
+  final case class Credentials(user: String, pass: String)
+  object Credentials {
+    implicit val credentialsCodec: Codec[Credentials] =
+      deriveCodec
+  }
+  final case class MavenRepository(name: String, location: String, credentials: Option[Credentials])
+      extends Resolver
+  final case class IvyRepository(name: String, pattern: String, credentials: Option[Credentials])
+      extends Resolver
 
   val mavenCentral: MavenRepository =
-    MavenRepository("public", "https://repo1.maven.org/maven2/")
+    MavenRepository("public", "https://repo1.maven.org/maven2/", None)
 
   implicit val resolverCodec: Codec[Resolver] =
     deriveCodec
 
   implicit val resolverOrder: Order[Resolver] =
     Order.by {
-      case MavenRepository(name, location) => (1, name, location)
-      case IvyRepository(name, pattern)    => (2, name, pattern)
+      case MavenRepository(name, location, _) => (1, name, location)
+      case IvyRepository(name, pattern, _)    => (2, name, pattern)
     }
 }
diff --git a/modules/plugin/src/main/scala/org/scalasteward/plugin/StewardPlugin.scala b/modules/plugin/src/main/scala/org/scalasteward/plugin/StewardPlugin.scala
index 0da69fa53c..cfc86e8e33 100644
--- a/modules/plugin/src/main/scala/org/scalasteward/plugin/StewardPlugin.scala
+++ b/modules/plugin/src/main/scala/org/scalasteward/plugin/StewardPlugin.scala
@@ -18,7 +18,9 @@ package org.scalasteward.plugin
 
 import sbt.Keys._
 import sbt._
+
 import scala.util.matching.Regex
+import scala.util.Try
 
 object StewardPlugin extends AutoPlugin {
   override def trigger: PluginTrigger = allRequirements
@@ -37,6 +39,7 @@ object StewardPlugin extends AutoPlugin {
       val buildRoot = baseDirectory.in(ThisBuild).value
       val scalaBinaryVersionValue = scalaBinaryVersion.value
       val scalaVersionValue = scalaVersion.value
+      val sbtCredentials = credentials.value
 
       val libraryDeps = libraryDependencies.value
         .filter(isDefinedInBuildFiles(_, sourcePositions, buildRoot))
@@ -49,11 +52,18 @@ object StewardPlugin extends AutoPlugin {
         )
       val dependencies = libraryDeps ++ scalafixDeps
 
+      def getCredentials(url: URL): Option[Resolver.Credentials] =
+        Try(Credentials.forHost(sbtCredentials, url.getHost)).toOption.flatten
+          .map(c => Resolver.Credentials(c.userName, c.passwd))
+
       val resolvers = fullResolvers.value.collect {
         case repo: MavenRepository if !repo.root.startsWith("file:") =>
-          Resolver.MavenRepository(repo.name, repo.root)
+          val creds = getCredentials(new URL(repo.root))
+          Resolver.MavenRepository(repo.name, repo.root, creds)
         case repo: URLRepository =>
-          Resolver.IvyRepository(repo.name, repo.patterns.ivyPatterns.mkString)
+          val ivyPatterns = repo.patterns.ivyPatterns.mkString
+          val creds = getCredentials(new URL(ivyPatterns))
+          Resolver.IvyRepository(repo.name, ivyPatterns, creds)
       }
 
       val sb = new StringBuilder()
@@ -177,23 +187,37 @@ object StewardPlugin extends AutoPlugin {
   }
 
   object Resolver {
-    final case class MavenRepository(name: String, location: String) extends Resolver {
+    final case class Credentials(user: String, pass: String) {
+      def asJson: String =
+        objToJson(
+          List("user" -> strToJson(user), "pass" -> strToJson(pass))
+        )
+    }
+
+    final case class MavenRepository(
+        name: String,
+        location: String,
+        credentials: Option[Credentials]
+    ) extends Resolver {
       override def asJson: String =
         objToJson(
           List(
             "MavenRepository" -> objToJson(
-              List("name" -> strToJson(name), "location" -> strToJson(location))
+              List("name" -> strToJson(name), "location" -> strToJson(location)) ++
+                credentials.map(c => "credentials" -> c.asJson).toList
             )
           )
         )
     }
 
-    final case class IvyRepository(name: String, pattern: String) extends Resolver {
+    final case class IvyRepository(name: String, pattern: String, credentials: Option[Credentials])
+        extends Resolver {
       override def asJson: String =
         objToJson(
           List(
             "IvyRepository" -> objToJson(
-              List("name" -> strToJson(name), "pattern" -> strToJson(pattern))
+              List("name" -> strToJson(name), "pattern" -> strToJson(pattern)) ++
+                credentials.map(c => "credentials" -> c.asJson).toList
             )
           )
         )


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
index db90f09038..e94c4ef41b 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
@@ -12,7 +12,8 @@ object TestSyntax {
     def withSbtPluginReleases: Scope[A] = {
       val sbtPluginReleases = IvyRepository(
         "sbt-plugin-releases",
-        "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[defaultPattern]"
+        "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[defaultPattern]",
+        None
       )
       Scope(self, List(sbtPluginReleases))
     }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/sbt/parserTest.scala b/modules/core/src/test/scala/org/scalasteward/core/sbt/parserTest.scala
index e34900d147..09a2073367 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/sbt/parserTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/sbt/parserTest.scala
@@ -1,7 +1,7 @@
 package org.scalasteward.core.sbt
 
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.Resolver.{IvyRepository, MavenRepository}
+import org.scalasteward.core.data.Resolver.{Credentials, IvyRepository, MavenRepository}
 import org.scalasteward.core.data.{ArtifactId, Scope}
 import org.scalasteward.core.sbt.data.SbtVersion
 import org.scalasteward.core.sbt.parser._
@@ -20,7 +20,7 @@ class parserTest extends AnyFunSuite with Matchers {
          |[info] { "groupId": "org.scala-lang", "artifactId": { "name": "scala-library", "maybeCrossName": null }, "version": "2.12.7" }
          |[info] { "groupId": "com.github.pathikrit", "artifactId": { "name": "better-files", "maybeCrossName": "better-files_2.12" }, "version": "3.6.0" }
          |[info] { "groupId": "org.typelevel", "artifactId": { "name": "cats-effect", "maybeCrossName": "cats-effect_2.12" }, "version": "1.0.0" }
-         |[info] { "MavenRepository": { "name": "confluent-release", "location": "http://packages.confluent.io/maven/" } }
+         |[info] { "MavenRepository": { "name": "confluent-release", "location": "http://packages.confluent.io/maven/", "credentials": { "user": "donny", "pass": "brasc0" } } }
          |[info] { "MavenRepository": { "name": "bintray-ovotech-maven", "location": "https://dl.bintray.com/ovotech/maven/" } }
          |[info] --- snip ---
          |sbt:project> stewardDependencies
@@ -29,6 +29,7 @@ class parserTest extends AnyFunSuite with Matchers {
          |[info] { "groupId": "com.eed3si9n", "artifactId": { "name": "sbt-assembly", "maybeCrossName": null }, "version": "0.14.8", "sbtVersion": "1.0", "configurations": "foo" }
          |[info] { "groupId": "com.geirsson", "artifactId": { "name": "sbt-scalafmt", "maybeCrossName": null }, "version": "1.6.0-RC4", "sbtVersion": "1.0" }
          |[info] { "IvyRepository" : { "name": "sbt-plugin-releases", "pattern": "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[organisation]/[module]/(scala_[scalaVersion]/)(sbt_[sbtVersion]/)([branch]/)[revision]/[type]s/[artifact](-[classifier]).[ext]" } }
+         |[info] { "IvyRepository" : { "name": "sbt-plugin-releases-with-creds", "pattern": "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[organisation]/[module]/(scala_[scalaVersion]/)(sbt_[sbtVersion]/)([branch]/)[revision]/[type]s/[artifact](-[classifier]).[ext]", "credentials": { "user": "tony", "pass": "m0ntana" } } }
          |[info] --- snip ---
          |""".stripMargin.linesIterator.toList
     val scopes = parseDependencies(lines)
@@ -40,8 +41,12 @@ class parserTest extends AnyFunSuite with Matchers {
           "org.typelevel" % ArtifactId("cats-effect", "cats-effect_2.12") % "1.0.0"
         ),
         List(
-          MavenRepository("bintray-ovotech-maven", "https://dl.bintray.com/ovotech/maven/"),
-          MavenRepository("confluent-release", "http://packages.confluent.io/maven/")
+          MavenRepository("bintray-ovotech-maven", "https://dl.bintray.com/ovotech/maven/", None),
+          MavenRepository(
+            "confluent-release",
+            "http://packages.confluent.io/maven/",
+            Some(Credentials("donny", "brasc0"))
+          )
         )
       ),
       Scope(
@@ -55,7 +60,13 @@ class parserTest extends AnyFunSuite with Matchers {
         List(
           IvyRepository(
             "sbt-plugin-releases",
-            "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[organisation]/[module]/(scala_[scalaVersion]/)(sbt_[sbtVersion]/)([branch]/)[revision]/[type]s/[artifact](-[classifier]).[ext]"
+            "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[organisation]/[module]/(scala_[scalaVersion]/)(sbt_[sbtVersion]/)([branch]/)[revision]/[type]s/[artifact](-[classifier]).[ext]",
+            None
+          ),
+          IvyRepository(
+            "sbt-plugin-releases-with-creds",
+            "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[organisation]/[module]/(scala_[scalaVersion]/)(sbt_[sbtVersion]/)([branch]/)[revision]/[type]s/[artifact](-[classifier]).[ext]",
+            Some(Credentials("tony", "m0ntana"))
           )
         )
       )