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

Issue:
Artifact migrations do not work for group updates
Which is not surprising because only `Update.Single` has the [`newerGroupId` and `newerArtifactId`](https://github.com/scala-steward-org/scala-steward/blob/299cdfbdeaa6099912d0085b6a26a710ce5ddf7e/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala#L69-L70) fields.

Pull request description:
This supports artifact migrations of multiple artifacts in one update. Prior to this change, this was not possible because the `newerGroupId` and `newerArtifactId` fields were discarded while `ForArtifactId` updates were grouped into `ForGroupId`. With this change, `ForGroupId` is just a non-empty list of `ForArtifactId`s, so that these fields are not lost.

Closes: #1974

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala b/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
index 336bf01679..cf6c824cd2 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
@@ -18,11 +18,9 @@ package org.scalasteward.core.data
 
 import cats.Order
 import cats.implicits._
-import io.circe.Decoder
-import io.circe.Encoder
-import io.circe.Json
 import io.circe.generic.semiauto._
 import io.circe.syntax._
+import io.circe.{Decoder, Encoder, HCursor, Json}
 import org.scalasteward.core.repoconfig.PullRequestGroup
 import org.scalasteward.core.util
 import org.scalasteward.core.util.Nel
@@ -52,6 +50,7 @@ object Update {
   }
 
   sealed trait Single extends Product with Serializable with Update {
+    def forArtifactIds: Nel[ForArtifactId]
     def crossDependencies: Nel[CrossDependency]
     def dependencies: Nel[Dependency]
     def groupId: GroupId
@@ -80,8 +79,8 @@ object Update {
     def withNewerVersions(versions: Nel[Version]): Update.Single = this match {
       case s @ ForArtifactId(_, _, _, _) =>
         s.copy(newerVersions = versions)
-      case g @ ForGroupId(_, _) =>
-        g.copy(newerVersions = versions)
+      case ForGroupId(forArtifactIds) =>
+        ForGroupId(forArtifactIds.map(_.copy(newerVersions = versions)))
     }
   }
 
@@ -91,6 +90,9 @@ object Update {
       newerGroupId: Option[GroupId] = None,
       newerArtifactId: Option[String] = None
   ) extends Single {
+    override def forArtifactIds: Nel[ForArtifactId] =
+      Nel.one(this)
+
     override def crossDependencies: Nel[CrossDependency] =
       Nel.one(crossDependency)
 
@@ -114,9 +116,11 @@ object Update {
   }
 
   final case class ForGroupId(
-      crossDependencies: Nel[CrossDependency],
-      newerVersions: Nel[Version]
+      forArtifactIds: Nel[ForArtifactId]
   ) extends Single {
+    override def crossDependencies: Nel[CrossDependency] =
+      forArtifactIds.map(_.crossDependency)
+
     override def dependencies: Nel[Dependency] =
       crossDependencies.flatMap(_.dependencies)
 
@@ -141,6 +145,9 @@ object Update {
     override def currentVersion: Version =
       dependencies.head.version
 
+    override def newerVersions: Nel[Version] =
+      forArtifactIds.head.newerVersions
+
     def artifactIdsPrefix: Option[MinLengthString[3]] =
       util.string.longestCommonPrefixGreater[3](artifactIds.map(_.name))
   }
@@ -168,8 +175,7 @@ object Update {
     val groups0 =
       updates.groupByNel(s => (s.groupId, s.currentVersion, s.newerVersions))
     val groups1 = groups0.values.map { group =>
-      if (group.tail.isEmpty) group.head
-      else ForGroupId(group.map(_.crossDependency), group.head.newerVersions)
+      if (group.tail.isEmpty) group.head else ForGroupId(group)
     }
     groups1.toList.distinct.sorted
   }
@@ -213,12 +219,20 @@ object Update {
     derived.mapJson(json => Json.obj("ForGroupId" -> json))
   }
 
-  implicit val ForGroupIdDecoder: Decoder[ForGroupId] = {
-    val derived = deriveDecoder[ForGroupId]
-    derived
+  private val oldForGroupIdDecoder: Decoder[ForGroupId] =
+    (c: HCursor) =>
+      for {
+        crossDependencies <- c.downField("crossDependencies").as[Nel[CrossDependency]]
+        newerVersions <- c.downField("newerVersions").as[Nel[Version]]
+        forArtifactIds = crossDependencies
+          .map(crossDependency => ForArtifactId(crossDependency, newerVersions))
+      } yield ForGroupId(forArtifactIds)
+
+  implicit val ForGroupIdDecoder: Decoder[ForGroupId] =
+    deriveDecoder[ForGroupId]
       .prepare(_.downField("ForGroupId"))
-      .or(derived.prepare(_.downField("Group")))
-  }
+      .or(oldForGroupIdDecoder.prepare(_.downField("ForGroupId")))
+      .or(oldForGroupIdDecoder.prepare(_.downField("Group")))
 
   implicit val GroupedEncoder: Encoder[Grouped] = {
     val derived = deriveEncoder[Grouped]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/update/Selector.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/update/Selector.scala
index 1af02c1cce..6c7f936844 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/update/Selector.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/update/Selector.scala
@@ -193,24 +193,23 @@ object Selector {
   private def moduleReplacements(
       update: Update.Single,
       modulePositions: List[ModulePosition]
-  ): List[Substring.Replacement] = {
-    val (newerGroupId, newerArtifactId) = update match {
-      case u: Update.ForArtifactId => (u.newerGroupId, u.newerArtifactId)
-      case _: Update.ForGroupId    => (None, None)
-    }
-    if (newerGroupId.isEmpty && newerArtifactId.isEmpty) List.empty
-    else {
-      val currentGroupId = update.groupId
-      val currentArtifactId = update.artifactIds.head
-      modulePositions
-        .filter { p =>
-          p.groupId.value === currentGroupId.value &&
-          currentArtifactId.names.contains_(p.artifactId.value)
-        }
-        .flatMap { p =>
-          newerGroupId.map(g => p.groupId.replaceWith(g.value)).toList ++
-            newerArtifactId.map(a => p.artifactId.replaceWith(a)).toList
-        }
+  ): List[Substring.Replacement] =
+    update.forArtifactIds.toList.flatMap { forArtifactId =>
+      val newerGroupId = forArtifactId.newerGroupId
+      val newerArtifactId = forArtifactId.newerArtifactId
+      if (newerGroupId.isEmpty && newerArtifactId.isEmpty) List.empty
+      else {
+        val currentGroupId = forArtifactId.groupId
+        val currentArtifactId = forArtifactId.artifactIds.head
+        modulePositions
+          .filter { p =>
+            p.groupId.value === currentGroupId.value &&
+            currentArtifactId.names.contains_(p.artifactId.value)
+          }
+          .flatMap { p =>
+            newerGroupId.map(g => p.groupId.replaceWith(g.value)).toList ++
+              newerArtifactId.map(a => p.artifactId.replaceWith(a)).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 b0f884bf53..125830752b 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
@@ -102,9 +102,8 @@ object TestSyntax {
     }
 
     def group: Update.ForGroupId = {
-      val crossDependencies =
-        self._2.map(aId => CrossDependency(Dependency(self._1, aId, self._3.v)))
-      Update.ForGroupId(crossDependencies, Nel.of(self._4.v))
+      val forArtifactIds = self._2.map(aId => ((self._1 % aId % self._3) %> self._4).single)
+      Update.ForGroupId(forArtifactIds)
     }
   }
 
@@ -112,9 +111,8 @@ object TestSyntax {
       private val self: (GroupId, Nel[Nel[ArtifactId]], String, String)
   ) extends AnyVal {
     def group: Update.ForGroupId = {
-      val crossDependencies =
-        self._2.map(aIds => CrossDependency(aIds.map(aId => Dependency(self._1, aId, self._3.v))))
-      Update.ForGroupId(crossDependencies, Nel.of(self._4.v))
+      val forArtifactIds = self._2.map(aIds => ((self._1 % aIds % self._3) %> self._4).single)
+      Update.ForGroupId(forArtifactIds)
     }
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
index 52b162a2ed..082c70d121 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
@@ -1,10 +1,10 @@
 package org.scalasteward.core.data
 
+import io.circe.Json
+import io.circe.syntax._
 import munit.FunSuite
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.util.Nel
-import io.circe.syntax._
-import io.circe.Json
 
 class UpdateTest extends FunSuite {
   test("Group.mainArtifactId") {
@@ -110,7 +110,7 @@ class UpdateTest extends FunSuite {
     val update2: Update.ForGroupId =
       ("org.scala-sbt".g % Nel.of("sbt-launch".a, "scripted-plugin".a) % "1.2.1" %> "1.2.4").group
 
-    val expected = Json.obj(
+    val oldJson = Json.obj(
       "ForGroupId" := Json.obj(
         "crossDependencies" := Json.arr(
           Json.arr(
@@ -144,7 +144,56 @@ class UpdateTest extends FunSuite {
       )
     )
 
-    assertEquals(update1.asJson, expected)
+    val newJson = Json.obj(
+      "ForGroupId" := Json.obj(
+        "forArtifactIds" := Json.arr(
+          Json.obj(
+            "ForArtifactId" := Json.obj(
+              "crossDependency" := Json.arr(
+                Json.obj(
+                  "groupId" := "org.scala-sbt",
+                  "artifactId" := Json.obj(
+                    "name" := "sbt-launch",
+                    "maybeCrossName" := None
+                  ),
+                  "version" := "1.2.1",
+                  "sbtVersion" := None,
+                  "scalaVersion" := None,
+                  "configurations" := None
+                )
+              ),
+              "newerVersions" := List("1.2.4"),
+              "newerGroupId" := None,
+              "newerArtifactId" := None
+            )
+          ),
+          Json.obj(
+            "ForArtifactId" := Json.obj(
+              "crossDependency" := Json.arr(
+                Json.obj(
+                  "groupId" := "org.scala-sbt",
+                  "artifactId" := Json.obj(
+                    "name" := "scripted-plugin",
+                    "maybeCrossName" := None
+                  ),
+                  "version" := "1.2.1",
+                  "sbtVersion" := None,
+                  "scalaVersion" := None,
+                  "configurations" := None
+                )
+              ),
+              "newerVersions" := List("1.2.4"),
+              "newerGroupId" := None,
+              "newerArtifactId" := None
+            )
+          )
+        )
+      )
+    )
+
+    assertEquals(oldJson.as[Update.ForGroupId], Right(update2))
+    assertEquals(newJson.as[Update.ForGroupId], Right(update2))
+    assertEquals(update1.asJson, newJson)
     assertEquals(update1.asJson, update2.asJson)
     assertEquals(update1.asJson.as[Update], update2.asJson.as[Update])
     assertEquals(update1.asJson.as[Update.ForGroupId], update2.asJson.as[Update.ForGroupId])
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
index a571435775..045971eb4a 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
@@ -388,6 +388,26 @@ class RewriteTest extends FunSuite {
     runApplyUpdate(update, original, expected)
   }
 
+  // https://github.com/scala-steward-org/scala-steward/issues/1974
+  test("artifact change: group update") {
+    val update1 = ("io.chrisdavenport".g % "log4cats-core".a % "1.2.1" %> "1.3.0").single
+      .copy(newerGroupId = Some("org.typelevel".g))
+    val update2 = ("io.chrisdavenport".g % "log4cats-slf4j".a % "1.2.1" %> "1.3.0").single
+      .copy(newerGroupId = Some("org.typelevel".g))
+    val update = Update.groupByGroupId(List(update1, update2)).head
+    val original = Map(
+      "Dependencies.scala" -> """val log4catsVersion = "1.2.1" """,
+      "build.sbt" -> """ "io.chrisdavenport" %% "log4cats-core" % log4catsVersion
+                       | "io.chrisdavenport" %% "log4cats-slf4j" % log4catsVersion """.stripMargin
+    )
+    val expected = Map(
+      "Dependencies.scala" -> """val log4catsVersion = "1.3.0" """,
+      "build.sbt" -> """ "org.typelevel" %% "log4cats-core" % log4catsVersion
+                       | "org.typelevel" %% "log4cats-slf4j" % log4catsVersion """.stripMargin
+    )
+    runApplyUpdate(update, original, expected)
+  }
+
   test("groupId and version change of Maven dependency") {
     val update = ("io.chrisdavenport".g % "log4cats".a % "1.1.1" %> "1.2.0").single
       .copy(newerGroupId = Some("org.typelevel".g))
@@ -900,11 +920,11 @@ class RewriteTest extends FunSuite {
     val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val filesInRepoDir = files.map { case (file, content) => repoDir / file -> content }
-    val obtained = MockState.empty
+    val state = MockState.empty
       .addFiles(filesInRepoDir.toSeq: _*)
       .flatMap(editAlg.applyUpdate(data, update).runS)
-      .map(_.files)
       .unsafeRunSync()
+    val obtained = state.files
       .map { case (file, content) => file.toString.replace(repoDir.toString + "/", "") -> content }
     assertEquals(obtained, expected)
   }