Repository: scala-steward-org/scala-steward
Pull request #3076 (2023-06-09 11:15:58)

Improve grouping feature
Solves 2 issues in the `grouping` feature:
1. Failed grouping PRs (using `Update.Group`) get stuck: When a grouping PR is created by Scalasteward and the PR fails to build, then scalasteward does not create a new PR when new versions are released, because an existing PR is still open and the branch names are not unique. Solution: Introduce unique branch names in the grouping feature. This is similar to `Update.Single`
2. Commit messages are unclear. Solution: Added artifactIds and versions to the commit message. This is similar to `Update.Single`


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aea8de3f..1b56af84 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest]
-        scala: [2.13.11]
+        scala: [2.13.10]
         java: [temurin@17, temurin@11]
     runs-on: ${{ matrix.os }}
     steps:
@@ -159,12 +159,12 @@ jobs:
             ~/Library/Caches/Coursier/v1
           key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }}
 
-      - name: Download target directories (2.13.11)
+      - name: Download target directories (2.13.10)
         uses: actions/download-artifact@v3
         with:
-          name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11
+          name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10
 
-      - name: Inflate target directories (2.13.11)
+      - name: Inflate target directories (2.13.10)
         run: |
           tar xf targets.tar
           rm targets.tar
diff --git a/.mergify.yml b/.mergify.yml
index cc069a7c..509781ad 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -17,8 +17,8 @@ pull_request_rules:
 - name: merge scala-steward's PRs
   conditions:
   - author=scala-steward
-  - status-success=Build and Test (ubuntu-latest, 2.13.11, temurin@17)
-  - status-success=Build and Test (ubuntu-latest, 2.13.11, temurin@11)
+  - status-success=Build and Test (ubuntu-latest, 2.13.10, temurin@17)
+  - status-success=Build and Test (ubuntu-latest, 2.13.10, temurin@11)
   actions:
     merge:
       method: squash
diff --git a/README.md b/README.md
index 90080f40..f9af1a74 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ Thanks goes to these wonderful people for contributing to Scala Steward:
 * [Stanislav Chetvertkov](https://github.com/stanislav-chetvertkov)
 * [sullis](https://github.com/sullis)
 * [TATSUNO Yasuhiro](https://github.com/exoego)
+* [Terry Hendrix](https://github.com/terryhendrix1990)
 * [Thomas Heslin](https://github.com/tjheslin1)
 * [Thomas Kaliakos](https://github.com/thomaska)
 * [Tim Steinbach](https://github.com/NeQuissimus)
diff --git a/build.sbt b/build.sbt
index 9af33032..52a137d5 100644
--- a/build.sbt
+++ b/build.sbt
@@ -21,7 +21,7 @@ val moduleCrossPlatformMatrix: Map[String, List[Platform]] = Map(
   "dummy" -> List(JVMPlatform)
 )
 
-val Scala213 = "2.13.11"
+val Scala213 = "2.13.10"
 
 /// sbt-typelevel configuration
 
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 47715924..d480fdf0 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -60,14 +60,16 @@ pullRequests.frequency = "7 days"
 #
 # Every field in a `filter` is optional but at least one must be provided.
 #
-# For grouping every update togeher a filter like {group = "*"} can be # provided.
+# For grouping every update together a filter like {group = "*"} can be # provided.
 #
+# To create a new PR for each unique combination of artifact-versions, include ${hash} in the name.
+# 
 # Default: []
 pullRequests.grouping = [
   { name = "patches", "title" = "Patch updates", "filter" = [{"version" = "patch"}] },
   { name = "minor_major", "title" = "Minor/major updates", "filter" = [{"version" = "minor"}, {"version" = "major"}] },
   { name = "typelevel", "title" = "Typelevel updates", "filter" = [{"group" = "org.typelevel"}, {"group" = "org.http4s"}] },
-  { name = "my_libraries", "filter" = [{"artifact" = "my-library"}, {"artifact" = "my-other-library", "group" = "my-org"}] },
+  { name = "my_libraries_${hash}", "filter" = [{"artifact" = "my-library"}, {"artifact" = "my-other-library", "group" = "my-org"}] },
   { name = "all", "title" = "Dependency updates", "filter" = [{"group" = "*"}] }
 ]
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
index 1c80eb71..b5b74129 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
@@ -52,10 +52,40 @@ object CommitMsg {
           .replace("${branchName}", baseBranch.map(_.name).orEmpty)
         CommitMsg(title = title)
       },
-      g =>
-        CommitMsg(title =
-          g.title.getOrElse(s"Update for group ${g.name}") +
-            baseBranch.fold("")(branch => s" in ${branch.name}")
-        )
+      g => CommitMsg(title = groupMessage(group = g, baseBranch))
     )
+
+  def groupMessage(group: Update.Grouped, baseBranch: Option[Branch]): String = {
+    def innerGroupMessage(shortNotation: Boolean) = {
+      val artifactsWithVersions = group.updates
+        .groupBy(_.nextVersion)
+        .map {
+          case (version, List(update)) =>
+            s"${update.artifactId.name} to ${version.value}"
+
+          case (version, updates) if shortNotation =>
+            val firstArtifactName = updates.headOption.map(_.artifactId.name).getOrElse("")
+            s"$firstArtifactName and ${updates.size - 1} more to ${version.value}"
+
+          case (version, updates) =>
+            s"${updates.map(_.artifactId.name).mkString(", ")} to ${version.value}"
+        }
+        .mkString(" - ")
+
+      val defaultTitle = group.title.getOrElse(s"Update for group ${group.name}") +
+        baseBranch.fold("")(branch => s" in ${branch.name}")
+
+      group.title
+        .getOrElse(defaultTitle)
+        .replace("${artifactVersions}", artifactsWithVersions)
+    }
+
+    val title = innerGroupMessage(shortNotation = false)
+
+    if (title.sizeIs <= 200) {
+      title
+    } else {
+      innerGroupMessage(true)
+    }
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/package.scala b/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
index dac87fd0..3cf4bd6a 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
@@ -30,7 +30,11 @@ package object git {
     val base = baseBranch.fold("")(branch => s"${branch.name}/")
     update.on(
       update = u => Branch(s"$updateBranchPrefix/$base${u.name}-${u.nextVersion}"),
-      grouped = g => Branch(s"$updateBranchPrefix/$base${g.name}")
+      grouped = g => {
+        val hashString = Math.abs(g.updates.map(_.nextVersion).sortBy(_.value).hashCode()).toString
+        val branch = s"$updateBranchPrefix/$base${g.name}"
+        Branch(branch.replace("${hash}", hashString))
+      }
     )
   }
 
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index afef6cd0..4a967b20 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -13,7 +13,7 @@ object Dependencies {
   val circeLiteral = "io.circe" %% "circe-literal" % circeGeneric.revision
   val circeParser = "io.circe" %% "circe-parser" % circeGeneric.revision
   val circeRefined = "io.circe" %% "circe-refined" % circeGeneric.revision
-  val commonsIo = "commons-io" % "commons-io" % "2.13.0"
+  val commonsIo = "commons-io" % "commons-io" % "2.12.0"
   val coursierCore = "io.get-coursier" %% "coursier" % "2.1.4"
   val coursierSbtMaven =
     "io.get-coursier" %% "coursier-sbt-maven-repository" % coursierCore.revision
@@ -34,7 +34,7 @@ object Dependencies {
   val jjwtApi = "io.jsonwebtoken" % "jjwt-api" % "0.11.5"
   val jjwtImpl = "io.jsonwebtoken" % "jjwt-impl" % jjwtApi.revision
   val jjwtJackson = "io.jsonwebtoken" % "jjwt-jackson" % jjwtApi.revision
-  val millVersion = "0.11.0"
+  val millVersion = "0.10.12"
   val millMain = "com.lihaoyi" % "mill-main_2.13" % millVersion
   val monocleCore = "dev.optics" %% "monocle-core" % "3.2.0"
   val munit = "org.scalameta" %% "munit" % "0.7.29"
@@ -46,5 +46,5 @@ object Dependencies {
   val scalacheck = "org.scalacheck" %% "scalacheck" % "1.17.0"
   val scalaStewardMillPluginArtifactName = "scala-steward-mill-plugin"
   val scalaStewardMillPlugin =
-    "org.scala-steward" % s"${scalaStewardMillPluginArtifactName}_mill0.10_2.13" % "0.18.0"
+    "org.scala-steward" % s"${scalaStewardMillPluginArtifactName}_mill0.10_2.13" % "0.17.1"
 }
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 5b0840d1..92f9fd47 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -5,7 +5,7 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0")
 addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.3.1")
 addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.10.0")
 addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")
-addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0")
+addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.9.0")
 addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.2")
 addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.5")
 addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1")


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/forge/ForgePackageTest.scala b/modules/core/src/test/scala/org/scalasteward/core/forge/ForgePackageTest.scala
index 5a620982..add62451 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/forge/ForgePackageTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/forge/ForgePackageTest.scala
@@ -49,6 +49,28 @@ class ForgePackageTest extends FunSuite {
       assertEquals(createBranch(GitHub, repo, updateBranch), s"foo:update/my-group")
       assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
     }
+  }
+
+  // Grouped updates with hash
+
+  {
+    val update = Update.Grouped(
+      name = "my-group-${hash}",
+      title = None,
+      updates = List(("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single)
+    )
+
+    val updateBranch = git.branchFor(update, None)
+
+    test("listingBranch (grouped) with $hash") {
+      assertEquals(listingBranch(GitHub, repo, updateBranch), s"foo/bar:update/my-group-1164623676")
+      assertEquals(listingBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
+
+    test("createBranch (grouped) with $hash") {
+      assertEquals(createBranch(GitHub, repo, updateBranch), s"foo:update/my-group-1164623676")
+      assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
 
   }
 
diff --git a/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
index eaaddd54..3ba6f27e 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
@@ -649,7 +649,7 @@ class NewPullRequestDataTest extends FunSuite {
     assertEquals(obtained, expected)
   }
 
-  test("from() should construct NewPullRequestData for groupped update") {
+  test("from() should construct NewPullRequestData for grouped update") {
     val update1 = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
     val update2 = ("com.example".g % "foo".a % "1.0.0" %> "2.0.0").single
     val update = Update.Grouped("my-group", None, List(update1, update2))
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/CommitMsgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/CommitMsgTest.scala
new file mode 100644
index 00000000..1d3852e8
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/CommitMsgTest.scala
@@ -0,0 +1,56 @@
+package org.scalasteward.core.git
+
+import munit.FunSuite
+import org.scalasteward.core.TestSyntax._
+import org.scalasteward.core.data.Update
+
+class CommitMsgTest extends FunSuite {
+  test("Create title for Update.Grouped") {
+    val update1 = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
+    val update2 = ("com.example".g % "foo".a % "1.0.0" %> "2.0.0").single
+
+    val update = Update.Grouped("my-group", Some("The PR title"), List(update1, update2))
+    assertEquals(CommitMsg.replaceVariables("")(update, None).title, "The PR title")
+  }
+
+  test("Create title for Update.Grouped with ${artifactVersions}") {
+    val update1 = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
+    val update2 = ("com.example".g % "foo".a % "1.0.0" %> "2.0.0").single
+    val update3 = ("com.example".g % "bar".a % "1.0.0" %> "2.0.0").single
+    val update4 = ("com.example".g % "baz".a % "1.0.0" %> "2.0.0").single
+
+    val update = Update.Grouped(
+      "my-group",
+      Some("Title - ${artifactVersions}"),
+      List(update1, update2, update3, update4)
+    )
+    assertEquals(
+      CommitMsg.replaceVariables("")(update, None).title,
+      "Title - foo, bar, baz to 2.0.0 - logback-classic to 1.2.3"
+    )
+  }
+
+  test(
+    "Do not create truncated title for Update.Grouped with ${artifactVersions} when 200 characters or less"
+  ) {
+    val singles = (0 to 23).map(i => ("com.example".g % s"foo-$i".a % "1.0.0" %> "2.0.0").single)
+    val update = Update.Grouped("my-group", Some("Title ---- ${artifactVersions}"), singles.toList)
+    val title = CommitMsg.replaceVariables("")(update, None).title
+    assertEquals(
+      title,
+      "Title ---- foo-0, foo-1, foo-2, foo-3, foo-4, foo-5, foo-6, foo-7, foo-8, foo-9, foo-10, foo-11, foo-12, foo-13, foo-14, foo-15, foo-16, foo-17, foo-18, foo-19, foo-20, foo-21, foo-22, foo-23 to 2.0.0"
+    )
+    assertEquals(title.size, 200)
+  }
+
+  test(
+    "Create truncated title for Update.Grouped with ${artifactVersions} when more than 200 characters"
+  ) {
+    val singles = (0 to 23).map(i => ("com.example".g % s"foo-$i".a % "1.0.0" %> "2.0.0").single)
+    val update = Update.Grouped("my-group", Some("Title ----- ${artifactVersions}"), singles.toList)
+    assertEquals(
+      CommitMsg.replaceVariables("")(update, None).title,
+      "Title ----- foo-0 and 23 more to 2.0.0"
+    )
+  }
+}